GameForge v0.2.0 - gradio.Server with custom frontend + ZeroGPU
Browse files- DEPLOY.md +42 -0
- Dockerfile +19 -0
- README.md +60 -8
- app.py +476 -0
- gameforge/__init__.py +0 -0
- gameforge/config/__init__.py +1 -0
- gameforge/config/__pycache__/__init__.cpython-312.pyc +0 -0
- gameforge/config/__pycache__/registry_loader.cpython-312.pyc +0 -0
- gameforge/config/prompts.py +313 -0
- gameforge/config/registry.yaml +401 -0
- gameforge/config/registry_loader.py +165 -0
- gameforge/engine/__init__.py +1 -0
- gameforge/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- gameforge/engine/__pycache__/converter.cpython-312.pyc +0 -0
- gameforge/engine/__pycache__/orchestrator.cpython-312.pyc +0 -0
- gameforge/engine/__pycache__/router.cpython-312.pyc +0 -0
- gameforge/engine/__pycache__/validator.cpython-312.pyc +0 -0
- gameforge/engine/batch.py +178 -0
- gameforge/engine/browser.py +166 -0
- gameforge/engine/converter.py +299 -0
- gameforge/engine/orchestrator.py +584 -0
- gameforge/engine/router.py +213 -0
- gameforge/engine/validator.py +198 -0
- pipelines/asset_pack.yaml +110 -0
- pipelines/audio.yaml +106 -0
- pipelines/character.yaml +109 -0
- pipelines/cutscene.yaml +91 -0
- pipelines/environment.yaml +104 -0
- pipelines/npc_voice.yaml +102 -0
- pipelines/prop.yaml +92 -0
- pipelines/ui_element.yaml +93 -0
- requirements.txt +8 -0
- static/css/app.css +391 -0
- static/index.html +224 -0
- static/js/app.js +360 -0
DEPLOY.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GameForge Space - HF Spaces Deployment
|
| 2 |
+
#
|
| 3 |
+
# To deploy this to Hugging Face Spaces:
|
| 4 |
+
#
|
| 5 |
+
# 1. Create a new Space on huggingface.co/new-space
|
| 6 |
+
# - SDK: Gradio
|
| 7 |
+
# - Hardware: ZeroGPU (free quota, 3.5 min/day)
|
| 8 |
+
#
|
| 9 |
+
# 2. Push this space/ directory:
|
| 10 |
+
# huggingface-cli upload jkorstad/gameforge-space ./space/
|
| 11 |
+
#
|
| 12 |
+
# 3. The app will be live at:
|
| 13 |
+
# https://huggingface.co/spaces/jkorstad/gameforge-space
|
| 14 |
+
#
|
| 15 |
+
# What you get:
|
| 16 |
+
# - Custom HTML/JS frontend (not Gradio components)
|
| 17 |
+
# - gradio.Server backend with FastAPI routes
|
| 18 |
+
# - ZeroGPU for model inference (free!)
|
| 19 |
+
# - Queuing and concurrency management
|
| 20 |
+
# - gradio_client API compatibility
|
| 21 |
+
# - MCP tool registration
|
| 22 |
+
# - SSE streaming support
|
| 23 |
+
#
|
| 24 |
+
# API endpoints (all gradio_client compatible):
|
| 25 |
+
# - /registry_info - List all models
|
| 26 |
+
# - /get_route - Get routing for a model
|
| 27 |
+
# - /list_pipelines - List pipeline definitions
|
| 28 |
+
# - /format_prompt - Format prompt with templates
|
| 29 |
+
# - /format_npc - Format NPC dialogue
|
| 30 |
+
# - /generate_image - Generate image via FLUX (ZeroGPU)
|
| 31 |
+
# - /generate_3d - Generate 3D mesh via TRELLIS.2 (ZeroGPU)
|
| 32 |
+
# - /generate_voice - Generate voice via MeloTTS (ZeroGPU)
|
| 33 |
+
# - /generate_video - Generate video via LTX 2.3 (ZeroGPU)
|
| 34 |
+
# - /generate_music - Generate music via ACE-Step (ZeroGPU)
|
| 35 |
+
# - /generate_sfx - Generate SFX via TangoFlux (ZeroGPU)
|
| 36 |
+
# - /list_assets - Browse generated assets
|
| 37 |
+
# - /validate_asset - Validate asset quality
|
| 38 |
+
# - /convert_asset - Convert asset format
|
| 39 |
+
#
|
| 40 |
+
# MCP tools:
|
| 41 |
+
# - gameforge_generate - Generate any asset type
|
| 42 |
+
# - gameforge_list_models - List available models
|
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system deps for ffmpeg (audio conversion)
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
ffmpeg \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Copy and install Python deps
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy app
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -1,15 +1,67 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: apache-2.0
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: GameForge
|
| 3 |
+
emoji: ⚔️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.25.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: apache-2.0
|
| 11 |
+
tags:
|
| 12 |
+
- game-development
|
| 13 |
+
- asset-generation
|
| 14 |
+
- ai-pipeline
|
| 15 |
+
- text-to-image
|
| 16 |
+
- text-to-3d
|
| 17 |
+
- text-to-video
|
| 18 |
+
- text-to-speech
|
| 19 |
+
- text-to-music
|
| 20 |
+
- zerogpu
|
| 21 |
+
models:
|
| 22 |
+
- black-forest-labs/FLUX.1-schnell
|
| 23 |
+
- microsoft/TRELLIS.2
|
| 24 |
+
- myshell-ai/MeloTTS
|
| 25 |
+
short_description: "AI Game Asset Pipeline - Free open-source models"
|
| 26 |
---
|
| 27 |
|
| 28 |
+
# GameForge - AI Game Asset Pipeline
|
| 29 |
+
|
| 30 |
+
Generate production-ready game assets using best-in-class open-source AI models.
|
| 31 |
+
Images, video, 3D meshes, audio, voice, music -- all from text prompts.
|
| 32 |
+
|
| 33 |
+
**26 of 27 models are FREE** via Hugging Face ZeroGPU Spaces.
|
| 34 |
+
|
| 35 |
+
## Asset Types
|
| 36 |
+
|
| 37 |
+
| Type | Model | License |
|
| 38 |
+
|------|-------|---------|
|
| 39 |
+
| Images | FLUX.1-schnell | Apache 2.0 |
|
| 40 |
+
| Video | LTX 2.3 Turbo | Apache 2.0 |
|
| 41 |
+
| 3D | TRELLIS.2 | MIT |
|
| 42 |
+
| Voice | MeloTTS | MIT |
|
| 43 |
+
| Music | ACE-Step | Apache 2.0 |
|
| 44 |
+
| SFX | TangoFlux | Apache 2.0 |
|
| 45 |
+
|
| 46 |
+
## API
|
| 47 |
+
|
| 48 |
+
This Space exposes a REST API via `gradio.Server`. You can call it programmatically:
|
| 49 |
+
|
| 50 |
+
```python
|
| 51 |
+
from gradio_client import Client
|
| 52 |
+
|
| 53 |
+
client = Client("jkorstad/gameforge")
|
| 54 |
+
|
| 55 |
+
# Generate an image
|
| 56 |
+
result = client.predict("/generate_image", {"prompt": "fantasy knight"})
|
| 57 |
+
|
| 58 |
+
# Generate NPC voice
|
| 59 |
+
result = client.predict("/generate_voice", {"text": "Welcome, traveler!"})
|
| 60 |
+
|
| 61 |
+
# List all models
|
| 62 |
+
result = client.predict("/registry_info")
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
## License
|
| 66 |
+
|
| 67 |
+
Apache-2.0
|
app.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GameForge Space Backend
|
| 3 |
+
=======================
|
| 4 |
+
HF Space using gradio.Server for custom frontend + ZeroGPU.
|
| 5 |
+
Serves the GameForge web app with queued, concurrent-safe API endpoints.
|
| 6 |
+
|
| 7 |
+
Deploy: push to HF Spaces with ZeroGPU hardware.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import sys
|
| 12 |
+
import json
|
| 13 |
+
import asyncio
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Optional, Dict, Any, List
|
| 16 |
+
|
| 17 |
+
import spaces
|
| 18 |
+
from gradio import Server
|
| 19 |
+
from gradio.data_classes import FileData
|
| 20 |
+
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 21 |
+
import yaml
|
| 22 |
+
|
| 23 |
+
# Add current dir to path for gameforge package
|
| 24 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 25 |
+
|
| 26 |
+
# Import GameForge modules
|
| 27 |
+
from gameforge.config.registry_loader import get_registry
|
| 28 |
+
from gameforge.config.prompts import get_template_for_asset, format_npc_dialogue, list_templates
|
| 29 |
+
from gameforge.engine.router import get_router
|
| 30 |
+
from gameforge.engine.validator import validate_asset
|
| 31 |
+
from gameforge.engine.converter import convert_asset, export_for_engine
|
| 32 |
+
|
| 33 |
+
# Initialize
|
| 34 |
+
registry = get_registry()
|
| 35 |
+
router = get_router()
|
| 36 |
+
|
| 37 |
+
# Storage for generated assets
|
| 38 |
+
STORAGE_DIR = Path("/tmp/gameforge_assets")
|
| 39 |
+
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
|
| 41 |
+
# Create the Server app
|
| 42 |
+
app = Server()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ============================================================================
|
| 46 |
+
# Static file serving
|
| 47 |
+
# ============================================================================
|
| 48 |
+
|
| 49 |
+
STATIC_DIR = Path(__file__).parent / "static"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@app.get("/")
|
| 53 |
+
async def homepage():
|
| 54 |
+
"""Serve the main frontend."""
|
| 55 |
+
html_path = STATIC_DIR / "index.html"
|
| 56 |
+
if html_path.exists():
|
| 57 |
+
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
| 58 |
+
return HTMLResponse("<h1>GameForge</h1><p>Frontend not found</p>")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@app.get("/static/{path:path}")
|
| 62 |
+
async def static_files(path: str):
|
| 63 |
+
"""Serve static assets (CSS, JS, fonts)."""
|
| 64 |
+
file_path = STATIC_DIR / path
|
| 65 |
+
if file_path.exists() and file_path.is_file():
|
| 66 |
+
media_types = {
|
| 67 |
+
".css": "text/css",
|
| 68 |
+
".js": "application/javascript",
|
| 69 |
+
".png": "image/png",
|
| 70 |
+
".jpg": "image/jpeg",
|
| 71 |
+
".svg": "image/svg+xml",
|
| 72 |
+
".woff2": "font/woff2",
|
| 73 |
+
".woff": "font/woff",
|
| 74 |
+
".ttf": "font/ttf",
|
| 75 |
+
}
|
| 76 |
+
ext = file_path.suffix.lower()
|
| 77 |
+
media_type = media_types.get(ext, "application/octet-stream")
|
| 78 |
+
return FileResponse(str(file_path), media_type=media_type)
|
| 79 |
+
return JSONResponse({"error": "Not found"}, status_code=404)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ============================================================================
|
| 83 |
+
# API Endpoints (gradio_client compatible via @app.api)
|
| 84 |
+
# ============================================================================
|
| 85 |
+
|
| 86 |
+
@app.api()
|
| 87 |
+
def registry_info() -> Dict[str, Any]:
|
| 88 |
+
"""Get registry summary and all models."""
|
| 89 |
+
summary = registry.summary()
|
| 90 |
+
models = []
|
| 91 |
+
for asset_type in registry.list_asset_types():
|
| 92 |
+
asset = registry.get_asset(asset_type)
|
| 93 |
+
if asset:
|
| 94 |
+
for variant, model in asset.variants.items():
|
| 95 |
+
models.append({
|
| 96 |
+
"asset_type": asset_type,
|
| 97 |
+
"variant": variant,
|
| 98 |
+
"model": model.model,
|
| 99 |
+
"type": model.type,
|
| 100 |
+
"license": model.license,
|
| 101 |
+
"hardware": model.hardware,
|
| 102 |
+
"status": model.status,
|
| 103 |
+
"free": model.is_free,
|
| 104 |
+
"commercial_safe": model.is_commercial_safe,
|
| 105 |
+
"space_id": model.space_id,
|
| 106 |
+
})
|
| 107 |
+
return {"summary": summary, "models": models}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.api()
|
| 111 |
+
def get_route(asset_type: str, variant: str = "primary") -> Dict[str, Any]:
|
| 112 |
+
"""Get routing info for a model."""
|
| 113 |
+
decision = router.route(asset_type, variant)
|
| 114 |
+
return decision.to_dict()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.api()
|
| 118 |
+
def list_pipelines() -> List[Dict[str, Any]]:
|
| 119 |
+
"""List available pipeline definitions."""
|
| 120 |
+
pipes_dir = Path(__file__).parent / "pipelines"
|
| 121 |
+
result = []
|
| 122 |
+
for path in sorted(pipes_dir.glob("*.yaml")):
|
| 123 |
+
try:
|
| 124 |
+
with open(path) as f:
|
| 125 |
+
data = yaml.safe_load(f)
|
| 126 |
+
result.append({
|
| 127 |
+
"name": path.stem,
|
| 128 |
+
"description": data.get("description", ""),
|
| 129 |
+
"version": data.get("version", ""),
|
| 130 |
+
"steps": len(data.get("steps", [])),
|
| 131 |
+
"defaults": data.get("defaults", {}),
|
| 132 |
+
})
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
| 135 |
+
return result
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.api()
|
| 139 |
+
def get_pipeline(name: str) -> Dict[str, Any]:
|
| 140 |
+
"""Get full pipeline definition."""
|
| 141 |
+
pipes_dir = Path(__file__).parent / "pipelines"
|
| 142 |
+
for path in pipes_dir.glob(f"{name}.yaml"):
|
| 143 |
+
with open(path) as f:
|
| 144 |
+
return yaml.safe_load(f)
|
| 145 |
+
return {"error": f"Pipeline not found: {name}"}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@app.api()
|
| 149 |
+
def format_prompt(asset_type: str, user_prompt: str, model_family: str = "") -> Dict[str, str]:
|
| 150 |
+
"""Format a prompt using game-specific templates."""
|
| 151 |
+
template = get_template_for_asset(asset_type, model_family)
|
| 152 |
+
if template:
|
| 153 |
+
return template.format(user_prompt)
|
| 154 |
+
return {"prompt": user_prompt, "negative_prompt": ""}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.api()
|
| 158 |
+
def format_npc(text: str, emotion: str = "neutral", speaker: str = "") -> Dict[str, str]:
|
| 159 |
+
"""Format NPC dialogue with emotion."""
|
| 160 |
+
return {"formatted": format_npc_dialogue(text, emotion, speaker)}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.api()
|
| 164 |
+
def list_templates_api() -> List[Dict[str, str]]:
|
| 165 |
+
"""List all prompt templates."""
|
| 166 |
+
templates = list_templates()
|
| 167 |
+
return [
|
| 168 |
+
{
|
| 169 |
+
"name": t.name,
|
| 170 |
+
"asset_type": t.asset_type,
|
| 171 |
+
"model_family": t.model_family,
|
| 172 |
+
"examples": t.example_prompts[:3],
|
| 173 |
+
}
|
| 174 |
+
for t in templates
|
| 175 |
+
]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ============================================================================
|
| 179 |
+
# GPU-accelerated generation endpoints (ZeroGPU)
|
| 180 |
+
# ============================================================================
|
| 181 |
+
|
| 182 |
+
@spaces.GPU(duration=60)
|
| 183 |
+
def _generate_image(prompt: str, negative_prompt: str = "", steps: int = 4) -> str:
|
| 184 |
+
"""Generate an image using the HF Inference API."""
|
| 185 |
+
from huggingface_hub import InferenceClient
|
| 186 |
+
import tempfile
|
| 187 |
+
|
| 188 |
+
token = os.environ.get("HF_TOKEN", "")
|
| 189 |
+
if not token:
|
| 190 |
+
for tp in [os.path.expanduser("~/.cache/huggingface/token"),
|
| 191 |
+
os.path.expanduser("~/.huggingface/token")]:
|
| 192 |
+
if os.path.isfile(tp):
|
| 193 |
+
token = open(tp).read().strip()
|
| 194 |
+
break
|
| 195 |
+
|
| 196 |
+
client = InferenceClient(token=token, provider="hf-inference")
|
| 197 |
+
image = client.text_to_image(
|
| 198 |
+
prompt,
|
| 199 |
+
model="black-forest-labs/FLUX.1-schnell",
|
| 200 |
+
negative_prompt=negative_prompt or None,
|
| 201 |
+
num_inference_steps=steps,
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
out_path = str(STORAGE_DIR / f"img_{hash(prompt) % 100000}.png")
|
| 205 |
+
image.save(out_path)
|
| 206 |
+
return out_path
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@app.api()
|
| 210 |
+
def generate_image(prompt: str, negative_prompt: str = "", steps: int = 4) -> FileData:
|
| 211 |
+
"""Generate an image from text. Returns PNG."""
|
| 212 |
+
out_path = _generate_image(prompt, negative_prompt, steps)
|
| 213 |
+
return FileData(path=out_path)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
@spaces.GPU(duration=120)
|
| 217 |
+
def _generate_3d(image_path: str) -> str:
|
| 218 |
+
"""Generate 3D mesh from image via TRELLIS.2 Space."""
|
| 219 |
+
from gradio_client import Client
|
| 220 |
+
import shutil
|
| 221 |
+
|
| 222 |
+
client = Client("microsoft/TRELLIS.2")
|
| 223 |
+
result = client.predict(image_path, api_name="/generate")
|
| 224 |
+
|
| 225 |
+
# Result is typically a file path or tuple
|
| 226 |
+
if isinstance(result, tuple):
|
| 227 |
+
mesh_path = result[0]
|
| 228 |
+
elif isinstance(result, str) and os.path.isfile(result):
|
| 229 |
+
mesh_path = result
|
| 230 |
+
else:
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
out_path = str(STORAGE_DIR / f"mesh_{hash(image_path) % 100000}.glb")
|
| 234 |
+
if isinstance(mesh_path, str) and os.path.isfile(mesh_path):
|
| 235 |
+
shutil.copy2(mesh_path, out_path)
|
| 236 |
+
return out_path
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@app.api()
|
| 241 |
+
def generate_3d(image_path: FileData) -> Optional[FileData]:
|
| 242 |
+
"""Generate 3D mesh from an image. Returns GLB."""
|
| 243 |
+
result = _generate_3d(image_path["path"])
|
| 244 |
+
if result:
|
| 245 |
+
return FileData(path=result)
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@spaces.GPU(duration=60)
|
| 250 |
+
def _generate_voice(text: str) -> str:
|
| 251 |
+
"""Generate voice via MeloTTS."""
|
| 252 |
+
from gradio_client import Client
|
| 253 |
+
import shutil
|
| 254 |
+
|
| 255 |
+
client = Client("mrfakename/MeloTTS")
|
| 256 |
+
result = client.predict(text, api_name="/synthesize")
|
| 257 |
+
|
| 258 |
+
if isinstance(result, tuple):
|
| 259 |
+
audio_path = result[0]
|
| 260 |
+
elif isinstance(result, str) and os.path.isfile(result):
|
| 261 |
+
audio_path = result
|
| 262 |
+
else:
|
| 263 |
+
return None
|
| 264 |
+
|
| 265 |
+
out_path = str(STORAGE_DIR / f"voice_{hash(text) % 100000}.wav")
|
| 266 |
+
if os.path.isfile(audio_path):
|
| 267 |
+
shutil.copy2(audio_path, out_path)
|
| 268 |
+
return out_path
|
| 269 |
+
return None
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
@app.api()
|
| 273 |
+
def generate_voice(text: str) -> Optional[FileData]:
|
| 274 |
+
"""Generate NPC voice from text. Returns WAV."""
|
| 275 |
+
result = _generate_voice(text)
|
| 276 |
+
if result:
|
| 277 |
+
return FileData(path=result)
|
| 278 |
+
return None
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@spaces.GPU(duration=120)
|
| 282 |
+
def _generate_video(prompt: str) -> str:
|
| 283 |
+
"""Generate video via LTX-2 Turbo."""
|
| 284 |
+
from gradio_client import Client
|
| 285 |
+
import shutil
|
| 286 |
+
|
| 287 |
+
client = Client("alexnasa/ltx-2-TURBO")
|
| 288 |
+
result = client.predict(prompt, api_name="/generate")
|
| 289 |
+
|
| 290 |
+
if isinstance(result, tuple):
|
| 291 |
+
video_path = result[0]
|
| 292 |
+
elif isinstance(result, str) and os.path.isfile(result):
|
| 293 |
+
video_path = result
|
| 294 |
+
else:
|
| 295 |
+
return None
|
| 296 |
+
|
| 297 |
+
out_path = str(STORAGE_DIR / f"video_{hash(prompt) % 100000}.mp4")
|
| 298 |
+
if os.path.isfile(video_path):
|
| 299 |
+
shutil.copy2(video_path, out_path)
|
| 300 |
+
return out_path
|
| 301 |
+
return None
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@app.api()
|
| 305 |
+
def generate_video(prompt: str) -> Optional[FileData]:
|
| 306 |
+
"""Generate a video from text. Returns MP4."""
|
| 307 |
+
result = _generate_video(prompt)
|
| 308 |
+
if result:
|
| 309 |
+
return FileData(path=result)
|
| 310 |
+
return None
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@spaces.GPU(duration=120)
|
| 314 |
+
def _generate_music(prompt: str) -> str:
|
| 315 |
+
"""Generate music via ACE-Step."""
|
| 316 |
+
from gradio_client import Client
|
| 317 |
+
import shutil
|
| 318 |
+
|
| 319 |
+
client = Client("victor/ace-step-jam")
|
| 320 |
+
result = client.predict(prompt, api_name="/predict")
|
| 321 |
+
|
| 322 |
+
if isinstance(result, tuple):
|
| 323 |
+
audio_path = result[0]
|
| 324 |
+
elif isinstance(result, str) and os.path.isfile(result):
|
| 325 |
+
audio_path = result
|
| 326 |
+
else:
|
| 327 |
+
return None
|
| 328 |
+
|
| 329 |
+
out_path = str(STORAGE_DIR / f"music_{hash(prompt) % 100000}.wav")
|
| 330 |
+
if os.path.isfile(audio_path):
|
| 331 |
+
shutil.copy2(audio_path, out_path)
|
| 332 |
+
return out_path
|
| 333 |
+
return None
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@app.api()
|
| 337 |
+
def generate_music(prompt: str) -> Optional[FileData]:
|
| 338 |
+
"""Generate music from text. Returns WAV."""
|
| 339 |
+
result = _generate_music(prompt)
|
| 340 |
+
if result:
|
| 341 |
+
return FileData(path=result)
|
| 342 |
+
return None
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
@spaces.GPU(duration=60)
|
| 346 |
+
def _generate_sfx(prompt: str) -> str:
|
| 347 |
+
"""Generate sound effect via TangoFlux."""
|
| 348 |
+
from gradio_client import Client
|
| 349 |
+
import shutil
|
| 350 |
+
|
| 351 |
+
client = Client("declare-lab/TangoFlux")
|
| 352 |
+
result = client.predict(prompt, api_name="/predict")
|
| 353 |
+
|
| 354 |
+
if isinstance(result, tuple):
|
| 355 |
+
audio_path = result[0]
|
| 356 |
+
elif isinstance(result, str) and os.path.isfile(result):
|
| 357 |
+
audio_path = result
|
| 358 |
+
else:
|
| 359 |
+
return None
|
| 360 |
+
|
| 361 |
+
out_path = str(STORAGE_DIR / f"sfx_{hash(prompt) % 100000}.wav")
|
| 362 |
+
if os.path.isfile(audio_path):
|
| 363 |
+
shutil.copy2(audio_path, out_path)
|
| 364 |
+
return out_path
|
| 365 |
+
return None
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
@app.api()
|
| 369 |
+
def generate_sfx(prompt: str) -> Optional[FileData]:
|
| 370 |
+
"""Generate sound effect from text. Returns WAV."""
|
| 371 |
+
result = _generate_sfx(prompt)
|
| 372 |
+
if result:
|
| 373 |
+
return FileData(path=result)
|
| 374 |
+
return None
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
# ============================================================================
|
| 378 |
+
# Asset management
|
| 379 |
+
# ============================================================================
|
| 380 |
+
|
| 381 |
+
@app.api()
|
| 382 |
+
def list_assets(folder: str = "") -> List[Dict[str, Any]]:
|
| 383 |
+
"""List generated assets."""
|
| 384 |
+
search_dir = STORAGE_DIR / folder if folder else STORAGE_DIR
|
| 385 |
+
if not search_dir.exists():
|
| 386 |
+
return []
|
| 387 |
+
assets = []
|
| 388 |
+
for f in sorted(search_dir.rglob("*")):
|
| 389 |
+
if f.is_file():
|
| 390 |
+
stat = f.stat()
|
| 391 |
+
assets.append({
|
| 392 |
+
"name": f.name,
|
| 393 |
+
"path": str(f),
|
| 394 |
+
"size": stat.st_size,
|
| 395 |
+
"format": f.suffix.lower(),
|
| 396 |
+
"modified": stat.st_mtime,
|
| 397 |
+
})
|
| 398 |
+
return assets
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
@app.api()
|
| 402 |
+
def delete_asset(path: str) -> Dict[str, bool]:
|
| 403 |
+
"""Delete a generated asset."""
|
| 404 |
+
p = Path(path)
|
| 405 |
+
if p.exists() and p.is_file() and p.is_relative_to(STORAGE_DIR):
|
| 406 |
+
p.unlink()
|
| 407 |
+
return {"deleted": True}
|
| 408 |
+
return {"deleted": False}
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
@app.api()
|
| 412 |
+
def validate_asset_api(path: str, checks: str = "file_exists,non_empty") -> Dict[str, Any]:
|
| 413 |
+
"""Validate an asset file."""
|
| 414 |
+
check_list = [c.strip() for c in checks.split(",")]
|
| 415 |
+
return validate_asset(path, check_list)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
@app.api()
|
| 419 |
+
def convert_asset_api(input_path: str, target_format: str, engine: str = "") -> Optional[FileData]:
|
| 420 |
+
"""Convert an asset format."""
|
| 421 |
+
eng = engine if engine else None
|
| 422 |
+
result = convert_asset(input_path, target_format, engine=eng)
|
| 423 |
+
if result:
|
| 424 |
+
return FileData(path=result)
|
| 425 |
+
return None
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
# ============================================================================
|
| 429 |
+
# MCP Tool Registration (for HF Spaces MCP support)
|
| 430 |
+
# ============================================================================
|
| 431 |
+
|
| 432 |
+
@app.mcp.tool()
|
| 433 |
+
def gameforge_generate(
|
| 434 |
+
asset_type: str,
|
| 435 |
+
prompt: str,
|
| 436 |
+
) -> str:
|
| 437 |
+
"""Generate a game asset (image, 3D, voice, music, video, SFX).
|
| 438 |
+
|
| 439 |
+
Args:
|
| 440 |
+
asset_type: Type of asset - "image", "3d", "voice", "music", "video", "sfx"
|
| 441 |
+
prompt: Description of the asset to generate
|
| 442 |
+
"""
|
| 443 |
+
generators = {
|
| 444 |
+
"image": _generate_image,
|
| 445 |
+
"3d": lambda p: _generate_3d(p), # Takes image path, would need intermediate
|
| 446 |
+
"voice": _generate_voice,
|
| 447 |
+
"music": _generate_music,
|
| 448 |
+
"video": _generate_video,
|
| 449 |
+
"sfx": _generate_sfx,
|
| 450 |
+
}
|
| 451 |
+
fn = generators.get(asset_type)
|
| 452 |
+
if fn:
|
| 453 |
+
result = fn(prompt)
|
| 454 |
+
return result or "Generation failed"
|
| 455 |
+
return f"Unknown asset type: {asset_type}"
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
@app.mcp.tool()
|
| 459 |
+
def gameforge_list_models() -> str:
|
| 460 |
+
"""List all available AI models for game asset generation."""
|
| 461 |
+
models = []
|
| 462 |
+
for asset_type in registry.list_asset_types():
|
| 463 |
+
asset = registry.get_asset(asset_type)
|
| 464 |
+
if asset:
|
| 465 |
+
for variant, model in asset.variants.items():
|
| 466 |
+
free = "FREE" if model.is_free else "PAID"
|
| 467 |
+
models.append(f"{asset_type}/{variant}: {model.model} [{free}]")
|
| 468 |
+
return "\n".join(models)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
# ============================================================================
|
| 472 |
+
# Launch
|
| 473 |
+
# ============================================================================
|
| 474 |
+
|
| 475 |
+
if __name__ == "__main__":
|
| 476 |
+
app.launch(show_error=True)
|
gameforge/__init__.py
ADDED
|
File without changes
|
gameforge/config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# GameForge Config
|
gameforge/config/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (154 Bytes). View file
|
|
|
gameforge/config/__pycache__/registry_loader.cpython-312.pyc
ADDED
|
Binary file (9.39 kB). View file
|
|
|
gameforge/config/prompts.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt Templates - Game-specific prompt engineering for each model and asset type.
|
| 3 |
+
Provides optimized prompts that extract the best quality from each model.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
from typing import Dict, Optional, Any, List
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class PromptTemplate:
|
| 13 |
+
"""A prompt template for a specific asset type and model."""
|
| 14 |
+
name: str
|
| 15 |
+
asset_type: str
|
| 16 |
+
model_family: str # e.g. "flux", "tts", "3d", "video"
|
| 17 |
+
system_prefix: str = ""
|
| 18 |
+
style_suffix: str = ""
|
| 19 |
+
negative_prompt: str = ""
|
| 20 |
+
quality_keywords: List[str] = field(default_factory=list)
|
| 21 |
+
example_prompts: List[str] = field(default_factory=list)
|
| 22 |
+
|
| 23 |
+
def format(self, user_prompt: str, **kwargs) -> Dict[str, str]:
|
| 24 |
+
"""Format the template with a user prompt."""
|
| 25 |
+
parts = []
|
| 26 |
+
if self.system_prefix:
|
| 27 |
+
parts.append(self.system_prefix.format(**kwargs))
|
| 28 |
+
parts.append(user_prompt)
|
| 29 |
+
if self.style_suffix:
|
| 30 |
+
parts.append(self.style_suffix.format(**kwargs))
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
"prompt": ", ".join(parts),
|
| 34 |
+
"negative_prompt": self.negative_prompt,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ============================================================================
|
| 39 |
+
# IMAGE TEMPLATES
|
| 40 |
+
# ============================================================================
|
| 41 |
+
|
| 42 |
+
CHARACTER_FLUX = PromptTemplate(
|
| 43 |
+
name="character_flux",
|
| 44 |
+
asset_type="images",
|
| 45 |
+
model_family="flux",
|
| 46 |
+
system_prefix=(
|
| 47 |
+
"game character concept art, full body, T-pose, clean studio background, "
|
| 48 |
+
"highly detailed, professional game asset"
|
| 49 |
+
),
|
| 50 |
+
style_suffix="4k, sharp focus, studio lighting",
|
| 51 |
+
negative_prompt="blurry, low quality, watermark, text, signature, cropped, deformed",
|
| 52 |
+
quality_keywords=["detailed", "sharp", "professional", "studio"],
|
| 53 |
+
example_prompts=[
|
| 54 |
+
"fantasy knight in silver armor with a glowing sword",
|
| 55 |
+
"cyberpunk female hacker with neon implants",
|
| 56 |
+
"forest elf archer in green leather armor",
|
| 57 |
+
],
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
PROP_FLUX = PromptTemplate(
|
| 61 |
+
name="prop_flux",
|
| 62 |
+
asset_type="images",
|
| 63 |
+
model_family="flux",
|
| 64 |
+
system_prefix=(
|
| 65 |
+
"game prop, single object, clean gray background, PBR-ready, "
|
| 66 |
+
"studio lighting, game asset"
|
| 67 |
+
),
|
| 68 |
+
style_suffix="high detail, sharp focus, turntable view",
|
| 69 |
+
negative_prompt="blurry, multiple objects, text, watermark, background clutter",
|
| 70 |
+
quality_keywords=["PBR", "clean", "detailed", "turntable"],
|
| 71 |
+
example_prompts=[
|
| 72 |
+
"enchanted longsword with blue runes glowing along the blade",
|
| 73 |
+
"wooden treasure chest with iron bands and a golden lock",
|
| 74 |
+
"health potion in a red glass flask with cork stopper",
|
| 75 |
+
],
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
UI_ICON_FLUX = PromptTemplate(
|
| 79 |
+
name="ui_icon_flux",
|
| 80 |
+
asset_type="images",
|
| 81 |
+
model_family="flux",
|
| 82 |
+
system_prefix=(
|
| 83 |
+
"game UI icon, flat design, clean edges, transparent background, "
|
| 84 |
+
"readable at small size, game interface element"
|
| 85 |
+
),
|
| 86 |
+
style_suffix="vector style, sharp edges, 128x128",
|
| 87 |
+
negative_prompt="photo, realistic, 3d render, text, blurry, gradient mess",
|
| 88 |
+
quality_keywords=["flat", "clean", "icon", "readable"],
|
| 89 |
+
example_prompts=[
|
| 90 |
+
"health potion icon, red flask with bubbles",
|
| 91 |
+
"shield icon, medieval steel buckler",
|
| 92 |
+
"map icon, rolled parchment with ribbon",
|
| 93 |
+
],
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
SKYBOX_FLUX = PromptTemplate(
|
| 97 |
+
name="skybox_flux",
|
| 98 |
+
asset_type="images",
|
| 99 |
+
model_family="flux",
|
| 100 |
+
system_prefix="game skybox, 360 degree equirectangular panorama, seamless",
|
| 101 |
+
style_suffix="atmospheric, high resolution, game environment",
|
| 102 |
+
negative_prompt="text, watermark, border, frame, seams, objects in foreground",
|
| 103 |
+
quality_keywords=["panorama", "seamless", "atmospheric"],
|
| 104 |
+
example_prompts=[
|
| 105 |
+
"dark fantasy night sky with two moons and purple aurora",
|
| 106 |
+
"sunset over a tropical ocean with scattered clouds",
|
| 107 |
+
"post-apocalyptic orange haze sky with distant city ruins",
|
| 108 |
+
],
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
TEXTURE_FLUX = PromptTemplate(
|
| 112 |
+
name="texture_flux",
|
| 113 |
+
asset_type="images",
|
| 114 |
+
model_family="flux",
|
| 115 |
+
system_prefix="seamless tileable texture, top-down view, PBR diffuse map",
|
| 116 |
+
style_suffix="high resolution, no seams, game texture",
|
| 117 |
+
negative_prompt="seams, borders, edges, frame, text, perspective, 3d objects",
|
| 118 |
+
quality_keywords=["seamless", "tileable", "PBR"],
|
| 119 |
+
example_prompts=[
|
| 120 |
+
"mossy cobblestone path with small plants growing between stones",
|
| 121 |
+
"rusty medieval iron plate with rivets and scratches",
|
| 122 |
+
"oak wood grain planks, warm brown tones",
|
| 123 |
+
],
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# ============================================================================
|
| 127 |
+
# VIDEO TEMPLATES
|
| 128 |
+
# ============================================================================
|
| 129 |
+
|
| 130 |
+
CUTSCENE_LTX = PromptTemplate(
|
| 131 |
+
name="cutscene_ltx",
|
| 132 |
+
asset_type="video",
|
| 133 |
+
model_family="ltx",
|
| 134 |
+
system_prefix="cinematic game cutscene",
|
| 135 |
+
style_suffix="smooth camera movement, game engine quality, 24fps",
|
| 136 |
+
negative_prompt="static, still, blurry, watermark, text",
|
| 137 |
+
quality_keywords=["cinematic", "smooth", "dynamic"],
|
| 138 |
+
example_prompts=[
|
| 139 |
+
"A dragon lands on a castle tower at sunset, camera slowly circles",
|
| 140 |
+
"Hero draws sword and charges across a battlefield, tracking shot",
|
| 141 |
+
"Mystical portal opens in a dark cave, camera pushes in",
|
| 142 |
+
],
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
CUTSCENE_WAN = PromptTemplate(
|
| 146 |
+
name="cutscene_wan",
|
| 147 |
+
asset_type="video",
|
| 148 |
+
model_family="wan",
|
| 149 |
+
system_prefix="high quality cinematic video",
|
| 150 |
+
style_suffix="smooth motion, detailed, realistic lighting",
|
| 151 |
+
negative_prompt="static, blurry, artifacts, low quality",
|
| 152 |
+
quality_keywords=["cinematic", "detailed", "smooth"],
|
| 153 |
+
example_prompts=[
|
| 154 |
+
"A knight walks through a burning village at night",
|
| 155 |
+
"Camera flies through an enchanted forest at golden hour",
|
| 156 |
+
],
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# ============================================================================
|
| 160 |
+
# 3D TEMPLATES
|
| 161 |
+
# ============================================================================
|
| 162 |
+
|
| 163 |
+
MESH_TRELLIS = PromptTemplate(
|
| 164 |
+
name="mesh_trellis",
|
| 165 |
+
asset_type="3d",
|
| 166 |
+
model_family="trellis",
|
| 167 |
+
system_prefix="",
|
| 168 |
+
style_suffix="",
|
| 169 |
+
negative_prompt="",
|
| 170 |
+
quality_keywords=["clean", "watertight", "manifold"],
|
| 171 |
+
example_prompts=[], # TRELLIS takes image input, not text
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
MESH_HUNYUAN = PromptTemplate(
|
| 175 |
+
name="mesh_hunyuan",
|
| 176 |
+
asset_type="3d",
|
| 177 |
+
model_family="hunyuan",
|
| 178 |
+
system_prefix="high quality 3D model",
|
| 179 |
+
style_suffix="detailed geometry, clean topology, game-ready",
|
| 180 |
+
negative_prompt="low poly, broken mesh, artifacts",
|
| 181 |
+
quality_keywords=["detailed", "clean", "game-ready"],
|
| 182 |
+
example_prompts=[
|
| 183 |
+
"fantasy sword with ornate crossguard",
|
| 184 |
+
"wooden barrel with metal bands",
|
| 185 |
+
],
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# ============================================================================
|
| 189 |
+
# VOICE / TTS TEMPLATES
|
| 190 |
+
# ============================================================================
|
| 191 |
+
|
| 192 |
+
NPC_VOICE_MeloTTS = PromptTemplate(
|
| 193 |
+
name="npc_voice_melotts",
|
| 194 |
+
asset_type="voice",
|
| 195 |
+
model_family="melotts",
|
| 196 |
+
system_prefix="",
|
| 197 |
+
style_suffix="",
|
| 198 |
+
negative_prompt="",
|
| 199 |
+
quality_keywords=["clear", "natural", "expressive"],
|
| 200 |
+
example_prompts=[
|
| 201 |
+
"Welcome, traveler. What brings you to our humble village?",
|
| 202 |
+
"The dungeon lies beyond the northern gate. Be careful.",
|
| 203 |
+
"I have wares if you have coin, friend.",
|
| 204 |
+
],
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Pre-format NPC dialogue with emotion markers
|
| 208 |
+
NPC_EMOTIONS = {
|
| 209 |
+
"neutral": "{dialogue}",
|
| 210 |
+
"friendly": "*cheerful tone* {dialogue}",
|
| 211 |
+
"threatening": "*menacing whisper* {dialogue}",
|
| 212 |
+
"mysterious": "*cryptic tone* {dialogue}",
|
| 213 |
+
"excited": "*enthusiastic* {dialogue}",
|
| 214 |
+
"sad": "*sorrowful* {dialogue}",
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# ============================================================================
|
| 218 |
+
# MUSIC TEMPLATES
|
| 219 |
+
# ============================================================================
|
| 220 |
+
|
| 221 |
+
BGM_ACE = PromptTemplate(
|
| 222 |
+
name="bgm_ace",
|
| 223 |
+
asset_type="music",
|
| 224 |
+
model_family="ace_step",
|
| 225 |
+
system_prefix="game background music",
|
| 226 |
+
style_suffix="loop-friendly, game soundtrack quality",
|
| 227 |
+
negative_prompt="vocals, speech, silence, abrupt ending",
|
| 228 |
+
quality_keywords=["loop", "atmospheric", "immersive"],
|
| 229 |
+
example_prompts=[
|
| 230 |
+
"epic orchestral battle theme, 120 bpm, horns and strings",
|
| 231 |
+
"ambient exploration music, gentle piano and soft strings",
|
| 232 |
+
"tense dungeon atmosphere, low drones and distant echoes",
|
| 233 |
+
"victorious fanfare, brass and timpani, triumphant",
|
| 234 |
+
],
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
SFX_TANGO = PromptTemplate(
|
| 238 |
+
name="sfx_tango",
|
| 239 |
+
asset_type="sfx",
|
| 240 |
+
model_family="tangoflux",
|
| 241 |
+
system_prefix="game sound effect",
|
| 242 |
+
style_suffix="clean, isolated, game audio quality",
|
| 243 |
+
negative_prompt="music, speech, background noise",
|
| 244 |
+
quality_keywords=["clean", "isolated", "crisp"],
|
| 245 |
+
example_prompts=[
|
| 246 |
+
"sword slash whoosh with metallic ring",
|
| 247 |
+
"door creaking open slowly in a dungeon",
|
| 248 |
+
"health potion pickup, glass clink and magical sparkle",
|
| 249 |
+
"footsteps on gravel path, steady walking pace",
|
| 250 |
+
"fireball cast, rushing flame burst with explosion",
|
| 251 |
+
],
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# ============================================================================
|
| 255 |
+
# TEMPLATE REGISTRY
|
| 256 |
+
# ============================================================================
|
| 257 |
+
|
| 258 |
+
TEMPLATES: Dict[str, PromptTemplate] = {
|
| 259 |
+
"character_flux": CHARACTER_FLUX,
|
| 260 |
+
"prop_flux": PROP_FLUX,
|
| 261 |
+
"ui_icon_flux": UI_ICON_FLUX,
|
| 262 |
+
"skybox_flux": SKYBOX_FLUX,
|
| 263 |
+
"texture_flux": TEXTURE_FLUX,
|
| 264 |
+
"cutscene_ltx": CUTSCENE_LTX,
|
| 265 |
+
"cutscene_wan": CUTSCENE_WAN,
|
| 266 |
+
"mesh_trellis": MESH_TRELLIS,
|
| 267 |
+
"mesh_hunyuan": MESH_HUNYUAN,
|
| 268 |
+
"npc_voice_melotts": NPC_VOICE_MeloTTS,
|
| 269 |
+
"bgm_ace": BGM_ACE,
|
| 270 |
+
"sfx_tango": SFX_TANGO,
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def get_template(name: str) -> Optional[PromptTemplate]:
|
| 275 |
+
"""Get a prompt template by name."""
|
| 276 |
+
return TEMPLATES.get(name)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def list_templates(asset_type: Optional[str] = None) -> List[PromptTemplate]:
|
| 280 |
+
"""List templates, optionally filtered by asset type."""
|
| 281 |
+
if asset_type:
|
| 282 |
+
return [t for t in TEMPLATES.values() if t.asset_type == asset_type]
|
| 283 |
+
return list(TEMPLATES.values())
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def get_template_for_asset(
|
| 287 |
+
asset_type: str,
|
| 288 |
+
model_family: str = "",
|
| 289 |
+
) -> Optional[PromptTemplate]:
|
| 290 |
+
"""Get the best template for an asset type and model family."""
|
| 291 |
+
# Try exact match first
|
| 292 |
+
if model_family:
|
| 293 |
+
for t in TEMPLATES.values():
|
| 294 |
+
if t.asset_type == asset_type and t.model_family == model_family:
|
| 295 |
+
return t
|
| 296 |
+
# Fall back to asset type match
|
| 297 |
+
for t in TEMPLATES.values():
|
| 298 |
+
if t.asset_type == asset_type:
|
| 299 |
+
return t
|
| 300 |
+
return None
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def format_npc_dialogue(
|
| 304 |
+
dialogue: str,
|
| 305 |
+
emotion: str = "neutral",
|
| 306 |
+
speaker: str = "",
|
| 307 |
+
) -> str:
|
| 308 |
+
"""Format NPC dialogue with emotion and speaker context."""
|
| 309 |
+
template = NPC_EMOTIONS.get(emotion, NPC_EMOTIONS["neutral"])
|
| 310 |
+
formatted = template.format(dialogue=dialogue)
|
| 311 |
+
if speaker:
|
| 312 |
+
formatted = f"[{speaker}] {formatted}"
|
| 313 |
+
return formatted
|
gameforge/config/registry.yaml
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GameForge Model Registry
|
| 2 |
+
# ========================
|
| 3 |
+
# Maps asset types to "best-in-class" free/commercially viable models.
|
| 4 |
+
# Update this file to swap models without touching code.
|
| 5 |
+
#
|
| 6 |
+
# Each entry has:
|
| 7 |
+
# model: HF model ID or Space repo_id
|
| 8 |
+
# type: "inference" (free API), "space" (Gradio Space), "pipeline" (local transformers)
|
| 9 |
+
# license: Commercial license type
|
| 10 |
+
# hardware: Required hardware tier
|
| 11 |
+
# params: Default parameters for generation
|
| 12 |
+
#
|
| 13 |
+
# STATUS KEY:
|
| 14 |
+
# production = verified working, commercial-safe
|
| 15 |
+
# beta = working but may have rough edges
|
| 16 |
+
# alpha = early stage, needs testing
|
| 17 |
+
|
| 18 |
+
version: "1.0.0"
|
| 19 |
+
updated: "2026-04-20"
|
| 20 |
+
|
| 21 |
+
# ============================================================
|
| 22 |
+
# IMAGES - 2D concept art, sprites, textures, UI elements
|
| 23 |
+
# ============================================================
|
| 24 |
+
images:
|
| 25 |
+
# Primary: Fast, Apache 2.0, great quality
|
| 26 |
+
primary:
|
| 27 |
+
model: "black-forest-labs/FLUX.1-schnell"
|
| 28 |
+
type: inference
|
| 29 |
+
license: apache-2.0
|
| 30 |
+
status: production
|
| 31 |
+
hardware: free
|
| 32 |
+
params:
|
| 33 |
+
num_inference_steps: 4
|
| 34 |
+
guidance_scale: 0.0
|
| 35 |
+
|
| 36 |
+
# Alternative: Higher quality SD 3.5
|
| 37 |
+
alternative:
|
| 38 |
+
model: "stabilityai/stable-diffusion-3.5-large"
|
| 39 |
+
type: inference
|
| 40 |
+
license: stability-community
|
| 41 |
+
status: production
|
| 42 |
+
hardware: free
|
| 43 |
+
params:
|
| 44 |
+
num_inference_steps: 28
|
| 45 |
+
guidance_scale: 5.0
|
| 46 |
+
|
| 47 |
+
# Texture specialist: Seamless tile generation
|
| 48 |
+
texture:
|
| 49 |
+
model: "black-forest-labs/FLUX.1-schnell"
|
| 50 |
+
type: inference
|
| 51 |
+
license: apache-2.0
|
| 52 |
+
status: production
|
| 53 |
+
hardware: free
|
| 54 |
+
params:
|
| 55 |
+
num_inference_steps: 4
|
| 56 |
+
prompt_prefix: "seamless tileable texture, top-down view, "
|
| 57 |
+
negative_prompt: "seams, borders, edges, frame"
|
| 58 |
+
|
| 59 |
+
# ============================================================
|
| 60 |
+
# VIDEO - Cutscenes, effects, animated sequences
|
| 61 |
+
# ============================================================
|
| 62 |
+
video:
|
| 63 |
+
# Primary: LTX 2.3 Distilled - cinematic with audio, ZeroGPU (free!)
|
| 64 |
+
primary:
|
| 65 |
+
model: "Lightricks/LTX-2.3"
|
| 66 |
+
type: space
|
| 67 |
+
space_id: "Lightricks/LTX-2-3"
|
| 68 |
+
license: apache-2.0
|
| 69 |
+
status: production
|
| 70 |
+
hardware: zerogpu
|
| 71 |
+
params:
|
| 72 |
+
num_frames: 121
|
| 73 |
+
fps: 24
|
| 74 |
+
api_name: "/generate"
|
| 75 |
+
|
| 76 |
+
# Fast: LTX-2 Turbo - rapid iteration, ZeroGPU
|
| 77 |
+
fast:
|
| 78 |
+
model: "Lightricks/LTX-2-Turbo"
|
| 79 |
+
type: space
|
| 80 |
+
space_id: "alexnasa/ltx-2-TURBO"
|
| 81 |
+
license: apache-2.0
|
| 82 |
+
status: production
|
| 83 |
+
hardware: zerogpu
|
| 84 |
+
params:
|
| 85 |
+
num_frames: 49
|
| 86 |
+
fps: 24
|
| 87 |
+
api_name: "/generate"
|
| 88 |
+
|
| 89 |
+
# Cinematic: Wan 2.2 14B - highest quality, ZeroGPU
|
| 90 |
+
cinematic:
|
| 91 |
+
model: "Wan-AI/Wan2.2-14B"
|
| 92 |
+
type: space
|
| 93 |
+
space_id: "r3gm/wan2-2-fp8da-aoti-preview"
|
| 94 |
+
license: apache-2.0
|
| 95 |
+
status: production
|
| 96 |
+
hardware: zerogpu
|
| 97 |
+
params:
|
| 98 |
+
num_frames: 81
|
| 99 |
+
fps: 24
|
| 100 |
+
api_name: "/predict"
|
| 101 |
+
|
| 102 |
+
# Animation: Wan 2.2 Animate - video editing, ZeroGPU
|
| 103 |
+
animate:
|
| 104 |
+
model: "Wan-AI/Wan2.2-Animate"
|
| 105 |
+
type: space
|
| 106 |
+
space_id: "alexnasa/Wan2.2-Animate-ZEROGPU"
|
| 107 |
+
license: apache-2.0
|
| 108 |
+
status: production
|
| 109 |
+
hardware: zerogpu
|
| 110 |
+
params:
|
| 111 |
+
api_name: "/predict"
|
| 112 |
+
|
| 113 |
+
# ============================================================
|
| 114 |
+
# 3D ASSETS - Characters, props, environment objects
|
| 115 |
+
# ============================================================
|
| 116 |
+
threed:
|
| 117 |
+
# Primary: TRELLIS.2 - high-fidelity 3D, ZeroGPU (free!)
|
| 118 |
+
primary:
|
| 119 |
+
model: "microsoft/TRELLIS.2"
|
| 120 |
+
type: space
|
| 121 |
+
space_id: "microsoft/TRELLIS.2"
|
| 122 |
+
license: mit
|
| 123 |
+
status: production
|
| 124 |
+
hardware: zerogpu
|
| 125 |
+
params:
|
| 126 |
+
ss_guidance_strength: 7.5
|
| 127 |
+
slat_guidance_strength: 3
|
| 128 |
+
api_name: "/generate"
|
| 129 |
+
|
| 130 |
+
# Fast: TripoSG - image to textured 3D, ZeroGPU
|
| 131 |
+
fast:
|
| 132 |
+
model: "VAST-AI/TripoSG"
|
| 133 |
+
type: space
|
| 134 |
+
space_id: "VAST-AI/TripoSG"
|
| 135 |
+
license: mit
|
| 136 |
+
status: production
|
| 137 |
+
hardware: zerogpu
|
| 138 |
+
params:
|
| 139 |
+
api_name: "/generate"
|
| 140 |
+
|
| 141 |
+
# Text-to-3D: Hunyuan3D-2.0, ZeroGPU
|
| 142 |
+
text_to_3d:
|
| 143 |
+
model: "tencent/Hunyuan3D-2"
|
| 144 |
+
type: space
|
| 145 |
+
space_id: "tencent/Hunyuan3D-2"
|
| 146 |
+
license: tencent-hunyuan
|
| 147 |
+
status: production
|
| 148 |
+
hardware: zerogpu
|
| 149 |
+
params:
|
| 150 |
+
mode: "text"
|
| 151 |
+
steps: 50
|
| 152 |
+
api_name: "/generation_with texture"
|
| 153 |
+
|
| 154 |
+
# Mesh: CraftsMan3D - high-fidelity mesh generation, ZeroGPU
|
| 155 |
+
mesh:
|
| 156 |
+
model: "wyysf/CraftsMan3D"
|
| 157 |
+
type: space
|
| 158 |
+
space_id: "wyysf/CraftsMan3D"
|
| 159 |
+
license: apache-2.0
|
| 160 |
+
status: production
|
| 161 |
+
hardware: zerogpu
|
| 162 |
+
params:
|
| 163 |
+
api_name: "/generate"
|
| 164 |
+
|
| 165 |
+
# World/Environment generation, ZeroGPU
|
| 166 |
+
world:
|
| 167 |
+
model: "tencent/HY-World-2.0"
|
| 168 |
+
type: space
|
| 169 |
+
space_id: "prithivMLmods/HY-World-2.0-Demo"
|
| 170 |
+
license: tencent-hunyuan
|
| 171 |
+
status: alpha
|
| 172 |
+
hardware: zerogpu
|
| 173 |
+
params:
|
| 174 |
+
mode: "text_to_world"
|
| 175 |
+
|
| 176 |
+
# ============================================================
|
| 177 |
+
# AUDIO - NPC voices, voice cloning
|
| 178 |
+
# ============================================================
|
| 179 |
+
voice:
|
| 180 |
+
# Primary: MeloTTS - MIT, simple text-to-speech, no reference needed
|
| 181 |
+
primary:
|
| 182 |
+
model: "myshell-ai/MeloTTS"
|
| 183 |
+
type: space
|
| 184 |
+
space_id: "mrfakename/MeloTTS"
|
| 185 |
+
license: mit
|
| 186 |
+
status: production
|
| 187 |
+
hardware: cpu-basic
|
| 188 |
+
params:
|
| 189 |
+
speed: 1.0
|
| 190 |
+
language: "EN"
|
| 191 |
+
api_name: "/synthesize"
|
| 192 |
+
|
| 193 |
+
# Voice cloning: F5-TTS - needs reference audio
|
| 194 |
+
clone:
|
| 195 |
+
model: "SWivid/F5-TTS"
|
| 196 |
+
type: space
|
| 197 |
+
space_id: "mrfakename/E2-F5-TTS"
|
| 198 |
+
license: mit
|
| 199 |
+
status: production
|
| 200 |
+
hardware: cpu-basic
|
| 201 |
+
params:
|
| 202 |
+
clone_mode: true
|
| 203 |
+
ref_audio_required: true
|
| 204 |
+
api_name: "/predict"
|
| 205 |
+
|
| 206 |
+
# Alternative: MeloTTS (same as primary, alias)
|
| 207 |
+
alternative:
|
| 208 |
+
model: "myshell-ai/MeloTTS"
|
| 209 |
+
type: space
|
| 210 |
+
space_id: "mrfakename/MeloTTS"
|
| 211 |
+
license: mit
|
| 212 |
+
status: production
|
| 213 |
+
hardware: cpu-basic
|
| 214 |
+
params:
|
| 215 |
+
speed: 1.0
|
| 216 |
+
language: "JP"
|
| 217 |
+
api_name: "/synthesize"
|
| 218 |
+
|
| 219 |
+
# ============================================================
|
| 220 |
+
# MUSIC - Background scores, themes, ambient
|
| 221 |
+
# ============================================================
|
| 222 |
+
music:
|
| 223 |
+
# Primary: ACE-Step - full music generation, ZeroGPU (free!)
|
| 224 |
+
primary:
|
| 225 |
+
model: "ACE-Step/ACE-Step"
|
| 226 |
+
type: space
|
| 227 |
+
space_id: "victor/ace-step-jam"
|
| 228 |
+
license: apache-2.0
|
| 229 |
+
status: production
|
| 230 |
+
hardware: zerogpu
|
| 231 |
+
params:
|
| 232 |
+
duration: 60
|
| 233 |
+
api_name: "/predict"
|
| 234 |
+
|
| 235 |
+
# Fast: DiffRhythm - blazing fast song generation, ZeroGPU
|
| 236 |
+
fast:
|
| 237 |
+
model: "ASLP-lab/DiffRhythm"
|
| 238 |
+
type: space
|
| 239 |
+
space_id: "ASLP-lab/DiffRhythm"
|
| 240 |
+
license: apache-2.0
|
| 241 |
+
status: production
|
| 242 |
+
hardware: zerogpu
|
| 243 |
+
params:
|
| 244 |
+
api_name: "/predict"
|
| 245 |
+
|
| 246 |
+
# Alternative: YuE music generator, ZeroGPU
|
| 247 |
+
alternative:
|
| 248 |
+
model: "YuE-music"
|
| 249 |
+
type: space
|
| 250 |
+
space_id: "innova-ai/YuE-music-generator-demo"
|
| 251 |
+
license: apache-2.0
|
| 252 |
+
status: production
|
| 253 |
+
hardware: zerogpu
|
| 254 |
+
params:
|
| 255 |
+
api_name: "/predict"
|
| 256 |
+
|
| 257 |
+
# Local fallback: MusicGen via transformers pipeline
|
| 258 |
+
local:
|
| 259 |
+
model: "facebook/musicgen-medium"
|
| 260 |
+
type: pipeline
|
| 261 |
+
license: cc-by-nc-4.0
|
| 262 |
+
status: production
|
| 263 |
+
hardware: local
|
| 264 |
+
params:
|
| 265 |
+
max_new_tokens: 256
|
| 266 |
+
|
| 267 |
+
# ============================================================
|
| 268 |
+
# SOUND EFFECTS
|
| 269 |
+
# ============================================================
|
| 270 |
+
sfx:
|
| 271 |
+
# Primary: TangoFlux - text to audio/SFX, ZeroGPU (free!)
|
| 272 |
+
primary:
|
| 273 |
+
model: "declare-lab/TangoFlux"
|
| 274 |
+
type: space
|
| 275 |
+
space_id: "declare-lab/TangoFlux"
|
| 276 |
+
license: apache-2.0
|
| 277 |
+
status: production
|
| 278 |
+
hardware: zerogpu
|
| 279 |
+
params:
|
| 280 |
+
api_name: "/predict"
|
| 281 |
+
|
| 282 |
+
# Alternative: Stable Audio Open, ZeroGPU
|
| 283 |
+
alternative:
|
| 284 |
+
model: "stabilityai/stable-audio-open-1.0"
|
| 285 |
+
type: space
|
| 286 |
+
space_id: "ameerazam08/stableaudio-open-1.0"
|
| 287 |
+
license: stability-community
|
| 288 |
+
status: production
|
| 289 |
+
hardware: zerogpu
|
| 290 |
+
params:
|
| 291 |
+
seconds_total: 10
|
| 292 |
+
api_name: "/predict"
|
| 293 |
+
|
| 294 |
+
# Local fallback
|
| 295 |
+
local:
|
| 296 |
+
model: "facebook/musicgen-small"
|
| 297 |
+
type: pipeline
|
| 298 |
+
license: cc-by-nc-4.0
|
| 299 |
+
status: production
|
| 300 |
+
hardware: local
|
| 301 |
+
params:
|
| 302 |
+
prompt_prefix: "SFX: "
|
| 303 |
+
max_new_tokens: 128
|
| 304 |
+
|
| 305 |
+
# ============================================================
|
| 306 |
+
# ANIMATION / RIGGING
|
| 307 |
+
# ============================================================
|
| 308 |
+
animation:
|
| 309 |
+
# Primary: AniGen - animatable 3D assets, ZeroGPU
|
| 310 |
+
primary:
|
| 311 |
+
model: "ani-gen"
|
| 312 |
+
type: space
|
| 313 |
+
space_id: "AniGen/AniGen"
|
| 314 |
+
license: unknown
|
| 315 |
+
status: alpha
|
| 316 |
+
hardware: zerogpu
|
| 317 |
+
params:
|
| 318 |
+
steps: 50
|
| 319 |
+
|
| 320 |
+
# Character animation via Wan 2.2 Animate, ZeroGPU
|
| 321 |
+
animate:
|
| 322 |
+
model: "Wan-AI/Wan2.2-Animate"
|
| 323 |
+
type: space
|
| 324 |
+
space_id: "alexnasa/Wan2.2-Animate-ZEROGPU"
|
| 325 |
+
license: apache-2.0
|
| 326 |
+
status: production
|
| 327 |
+
hardware: zerogpu
|
| 328 |
+
params:
|
| 329 |
+
api_name: "/predict"
|
| 330 |
+
|
| 331 |
+
# Auto-rig via Mixamo (external API)
|
| 332 |
+
rigging:
|
| 333 |
+
model: "mixamo"
|
| 334 |
+
type: external
|
| 335 |
+
license: adobe-free
|
| 336 |
+
status: production
|
| 337 |
+
hardware: cloud
|
| 338 |
+
params:
|
| 339 |
+
auto_detect_face: true
|
| 340 |
+
skeleton_type: "mixamo_game"
|
| 341 |
+
|
| 342 |
+
# ============================================================
|
| 343 |
+
# ENVIRONMENT / SKYBOX
|
| 344 |
+
# ============================================================
|
| 345 |
+
environment:
|
| 346 |
+
# 360 skybox generation
|
| 347 |
+
skybox:
|
| 348 |
+
model: "black-forest-labs/FLUX.1-schnell"
|
| 349 |
+
type: inference
|
| 350 |
+
license: apache-2.0
|
| 351 |
+
status: production
|
| 352 |
+
hardware: free
|
| 353 |
+
params:
|
| 354 |
+
prompt_suffix: ", 360 degree equirectangular panorama, seamless"
|
| 355 |
+
width: 2048
|
| 356 |
+
height: 1024
|
| 357 |
+
|
| 358 |
+
# 3D world generation, ZeroGPU
|
| 359 |
+
world:
|
| 360 |
+
model: "tencent/HY-World-2.0"
|
| 361 |
+
type: space
|
| 362 |
+
space_id: "prithivMLmods/HY-World-2.0-Demo"
|
| 363 |
+
license: tencent-hunyuan
|
| 364 |
+
status: alpha
|
| 365 |
+
hardware: zerogpu
|
| 366 |
+
params:
|
| 367 |
+
resolution: 512
|
| 368 |
+
|
| 369 |
+
# ============================================================
|
| 370 |
+
# UTILITY MODELS (used by orchestrator)
|
| 371 |
+
# ============================================================
|
| 372 |
+
utility:
|
| 373 |
+
# Image captioning for pipeline chaining
|
| 374 |
+
captioner:
|
| 375 |
+
model: "Salesforce/blip-image-captioning-large"
|
| 376 |
+
type: pipeline
|
| 377 |
+
license: bsd-3
|
| 378 |
+
status: production
|
| 379 |
+
hardware: local
|
| 380 |
+
params: {}
|
| 381 |
+
|
| 382 |
+
# Text enhancement for prompts
|
| 383 |
+
prompt_enhancer:
|
| 384 |
+
model: "Qwen/Qwen2.5-72B-Instruct"
|
| 385 |
+
type: inference
|
| 386 |
+
license: apache-2.0
|
| 387 |
+
status: production
|
| 388 |
+
hardware: free
|
| 389 |
+
params:
|
| 390 |
+
max_tokens: 512
|
| 391 |
+
temperature: 0.8
|
| 392 |
+
|
| 393 |
+
# Image upscaler
|
| 394 |
+
upscaler:
|
| 395 |
+
model: "stabilityai/stable-diffusion-x4-upscaler"
|
| 396 |
+
type: inference
|
| 397 |
+
license: stability-community
|
| 398 |
+
status: production
|
| 399 |
+
hardware: free
|
| 400 |
+
params:
|
| 401 |
+
num_inference_steps: 20
|
gameforge/config/registry_loader.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Registry Loader - Loads and validates the model registry YAML.
|
| 3 |
+
Provides lookup by asset type and variant.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Optional, Dict, Any, List
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
import yaml
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
REGISTRY_PATH = Path(__file__).parent.parent / "config" / "registry.yaml"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class ModelEntry:
|
| 18 |
+
"""A single model entry from the registry."""
|
| 19 |
+
model: str
|
| 20 |
+
type: str # inference, space, pipeline, external
|
| 21 |
+
license: str
|
| 22 |
+
status: str
|
| 23 |
+
hardware: str
|
| 24 |
+
params: Dict[str, Any] = field(default_factory=dict)
|
| 25 |
+
space_id: Optional[str] = None
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def is_free(self) -> bool:
|
| 29 |
+
return self.hardware in ("free", "local", "cpu-basic", "zerogpu")
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def is_commercial_safe(self) -> bool:
|
| 33 |
+
safe_licenses = {"apache-2.0", "mit", "bsd-3", "stability-community", "adobe-free"}
|
| 34 |
+
return self.license.lower() in safe_licenses
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class AssetConfig:
|
| 39 |
+
"""Configuration for an asset type (images, video, 3d, etc.)."""
|
| 40 |
+
name: str
|
| 41 |
+
variants: Dict[str, ModelEntry] = field(default_factory=dict)
|
| 42 |
+
|
| 43 |
+
def get(self, variant: str = "primary") -> Optional[ModelEntry]:
|
| 44 |
+
return self.variants.get(variant)
|
| 45 |
+
|
| 46 |
+
def list_variants(self) -> List[str]:
|
| 47 |
+
return list(self.variants.keys())
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class Registry:
|
| 51 |
+
"""Loads and queries the GameForge model registry."""
|
| 52 |
+
|
| 53 |
+
def __init__(self, path: Optional[str] = None):
|
| 54 |
+
self.path = Path(path) if path else REGISTRY_PATH
|
| 55 |
+
self._data: Dict[str, Any] = {}
|
| 56 |
+
self._assets: Dict[str, AssetConfig] = {}
|
| 57 |
+
self._utility: Dict[str, ModelEntry] = {}
|
| 58 |
+
self.load()
|
| 59 |
+
|
| 60 |
+
def load(self) -> None:
|
| 61 |
+
"""Load or reload the registry from YAML."""
|
| 62 |
+
with open(self.path) as f:
|
| 63 |
+
self._data = yaml.safe_load(f)
|
| 64 |
+
|
| 65 |
+
# Parse asset types (skip version, updated, utility)
|
| 66 |
+
skip = {"version", "updated", "utility"}
|
| 67 |
+
for key, value in self._data.items():
|
| 68 |
+
if key in skip or not isinstance(value, dict):
|
| 69 |
+
continue
|
| 70 |
+
variants = {}
|
| 71 |
+
for var_name, var_data in value.items():
|
| 72 |
+
if isinstance(var_data, dict) and "model" in var_data:
|
| 73 |
+
variants[var_name] = ModelEntry(
|
| 74 |
+
model=var_data.get("model", ""),
|
| 75 |
+
type=var_data.get("type", "inference"),
|
| 76 |
+
license=var_data.get("license", "unknown"),
|
| 77 |
+
status=var_data.get("status", "alpha"),
|
| 78 |
+
hardware=var_data.get("hardware", "free"),
|
| 79 |
+
params=var_data.get("params", {}),
|
| 80 |
+
space_id=var_data.get("space_id"),
|
| 81 |
+
)
|
| 82 |
+
if variants:
|
| 83 |
+
self._assets[key] = AssetConfig(name=key, variants=variants)
|
| 84 |
+
|
| 85 |
+
# Parse utility models
|
| 86 |
+
util_data = self._data.get("utility", {})
|
| 87 |
+
for name, data in util_data.items():
|
| 88 |
+
if isinstance(data, dict) and "model" in data:
|
| 89 |
+
self._utility[name] = ModelEntry(
|
| 90 |
+
model=data.get("model", ""),
|
| 91 |
+
type=data.get("type", "inference"),
|
| 92 |
+
license=data.get("license", "unknown"),
|
| 93 |
+
status=data.get("status", "alpha"),
|
| 94 |
+
hardware=data.get("hardware", "free"),
|
| 95 |
+
params=data.get("params", {}),
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def version(self) -> str:
|
| 100 |
+
return self._data.get("version", "unknown")
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def updated(self) -> str:
|
| 104 |
+
return self._data.get("updated", "unknown")
|
| 105 |
+
|
| 106 |
+
def list_asset_types(self) -> List[str]:
|
| 107 |
+
"""List all registered asset types."""
|
| 108 |
+
return list(self._assets.keys())
|
| 109 |
+
|
| 110 |
+
def get_asset(self, asset_type: str) -> Optional[AssetConfig]:
|
| 111 |
+
"""Get the full config for an asset type."""
|
| 112 |
+
return self._assets.get(asset_type)
|
| 113 |
+
|
| 114 |
+
def get_model(self, asset_type: str, variant: str = "primary") -> Optional[ModelEntry]:
|
| 115 |
+
"""Get a specific model entry."""
|
| 116 |
+
asset = self._assets.get(asset_type)
|
| 117 |
+
if asset:
|
| 118 |
+
return asset.get(variant)
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
def get_utility(self, name: str) -> Optional[ModelEntry]:
|
| 122 |
+
"""Get a utility model (captioner, prompt_enhancer, etc.)."""
|
| 123 |
+
return self._utility.get(name)
|
| 124 |
+
|
| 125 |
+
def find_commercial_safe(self) -> Dict[str, List[str]]:
|
| 126 |
+
"""Find all commercially safe models grouped by asset type."""
|
| 127 |
+
result = {}
|
| 128 |
+
for asset_type, config in self._assets.items():
|
| 129 |
+
safe = [v for v, m in config.variants.items() if m.is_commercial_safe]
|
| 130 |
+
if safe:
|
| 131 |
+
result[asset_type] = safe
|
| 132 |
+
return result
|
| 133 |
+
|
| 134 |
+
def find_free(self) -> Dict[str, List[str]]:
|
| 135 |
+
"""Find all free-tier models grouped by asset type."""
|
| 136 |
+
result = {}
|
| 137 |
+
for asset_type, config in self._assets.items():
|
| 138 |
+
free = [v for v, m in config.variants.items() if m.is_free]
|
| 139 |
+
if free:
|
| 140 |
+
result[asset_type] = free
|
| 141 |
+
return result
|
| 142 |
+
|
| 143 |
+
def summary(self) -> Dict[str, Any]:
|
| 144 |
+
"""Get a summary of the registry."""
|
| 145 |
+
return {
|
| 146 |
+
"version": self.version,
|
| 147 |
+
"updated": self.updated,
|
| 148 |
+
"asset_types": len(self._assets),
|
| 149 |
+
"total_models": sum(len(a.variants) for a in self._assets.values()),
|
| 150 |
+
"utility_models": len(self._utility),
|
| 151 |
+
"commercial_safe": len(self.find_commercial_safe()),
|
| 152 |
+
"free_tier": len(self.find_free()),
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# Singleton
|
| 157 |
+
_registry: Optional[Registry] = None
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def get_registry(path: Optional[str] = None) -> Registry:
|
| 161 |
+
"""Get the global registry singleton."""
|
| 162 |
+
global _registry
|
| 163 |
+
if _registry is None or path:
|
| 164 |
+
_registry = Registry(path)
|
| 165 |
+
return _registry
|
gameforge/engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# GameForge Engine
|
gameforge/engine/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (154 Bytes). View file
|
|
|
gameforge/engine/__pycache__/converter.cpython-312.pyc
ADDED
|
Binary file (11 kB). View file
|
|
|
gameforge/engine/__pycache__/orchestrator.cpython-312.pyc
ADDED
|
Binary file (29.1 kB). View file
|
|
|
gameforge/engine/__pycache__/router.cpython-312.pyc
ADDED
|
Binary file (8.74 kB). View file
|
|
|
gameforge/engine/__pycache__/validator.cpython-312.pyc
ADDED
|
Binary file (8.98 kB). View file
|
|
|
gameforge/engine/batch.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Batch Generator - Generate multiple variants of assets in one call.
|
| 3 |
+
Supports parallel generation and variant management.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional, Dict, Any, List, Callable
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
from gameforge.engine.orchestrator import Orchestrator, PipelineRun, get_orchestrator
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class BatchConfig:
|
| 20 |
+
"""Configuration for a batch generation run."""
|
| 21 |
+
pipeline: str
|
| 22 |
+
base_prompt: str
|
| 23 |
+
count: int = 3
|
| 24 |
+
variant_seeds: List[int] = field(default_factory=list)
|
| 25 |
+
inputs_overrides: Dict[str, Any] = field(default_factory=dict)
|
| 26 |
+
output_prefix: str = "variant"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class BatchResult:
|
| 31 |
+
"""Result of a batch generation run."""
|
| 32 |
+
batch_name: str
|
| 33 |
+
pipeline: str
|
| 34 |
+
total: int
|
| 35 |
+
succeeded: int
|
| 36 |
+
failed: int
|
| 37 |
+
runs: List[PipelineRun] = field(default_factory=list)
|
| 38 |
+
started_at: str = ""
|
| 39 |
+
completed_at: str = ""
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def success_rate(self) -> float:
|
| 43 |
+
return self.succeeded / self.total if self.total > 0 else 0
|
| 44 |
+
|
| 45 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 46 |
+
return {
|
| 47 |
+
"batch": self.batch_name,
|
| 48 |
+
"pipeline": self.pipeline,
|
| 49 |
+
"total": self.total,
|
| 50 |
+
"succeeded": self.succeeded,
|
| 51 |
+
"failed": self.failed,
|
| 52 |
+
"success_rate": f"{self.success_rate:.0%}",
|
| 53 |
+
"runs": [r.to_dict() for r in self.runs],
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class BatchGenerator:
|
| 58 |
+
"""
|
| 59 |
+
Generate multiple asset variants using pipelines.
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
def __init__(self, output_dir: Optional[str] = None):
|
| 63 |
+
self.orchestrator = get_orchestrator(output_dir)
|
| 64 |
+
|
| 65 |
+
def generate_variants(
|
| 66 |
+
self,
|
| 67 |
+
pipeline: str,
|
| 68 |
+
base_prompt: str,
|
| 69 |
+
count: int = 3,
|
| 70 |
+
variation_fn: Optional[Callable[[str, int], str]] = None,
|
| 71 |
+
dry_run: bool = False,
|
| 72 |
+
**kwargs,
|
| 73 |
+
) -> BatchResult:
|
| 74 |
+
"""
|
| 75 |
+
Generate N variants of an asset.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
pipeline: Pipeline name
|
| 79 |
+
base_prompt: Base prompt to vary
|
| 80 |
+
count: Number of variants
|
| 81 |
+
variation_fn: Function to create prompt variations (prompt, index) -> varied_prompt
|
| 82 |
+
dry_run: Preview without executing
|
| 83 |
+
"""
|
| 84 |
+
if variation_fn is None:
|
| 85 |
+
variation_fn = self._default_variation
|
| 86 |
+
|
| 87 |
+
batch = BatchResult(
|
| 88 |
+
batch_name=f"{pipeline}_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
| 89 |
+
pipeline=pipeline,
|
| 90 |
+
total=count,
|
| 91 |
+
succeeded=0,
|
| 92 |
+
failed=0,
|
| 93 |
+
started_at=datetime.now().isoformat(),
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
for i in range(count):
|
| 97 |
+
varied_prompt = variation_fn(base_prompt, i)
|
| 98 |
+
inputs = {"prompt": varied_prompt, **kwargs}
|
| 99 |
+
|
| 100 |
+
run = self.orchestrator.run(pipeline, inputs=inputs, dry_run=dry_run)
|
| 101 |
+
batch.runs.append(run)
|
| 102 |
+
|
| 103 |
+
if run.success:
|
| 104 |
+
batch.succeeded += 1
|
| 105 |
+
else:
|
| 106 |
+
batch.failed += 1
|
| 107 |
+
|
| 108 |
+
logger.info(f"Variant {i+1}/{count}: {'OK' if run.success else 'FAIL'}")
|
| 109 |
+
|
| 110 |
+
batch.completed_at = datetime.now().isoformat()
|
| 111 |
+
return batch
|
| 112 |
+
|
| 113 |
+
def generate_from_themes(
|
| 114 |
+
self,
|
| 115 |
+
pipeline: str,
|
| 116 |
+
themes: List[str],
|
| 117 |
+
dry_run: bool = False,
|
| 118 |
+
**kwargs,
|
| 119 |
+
) -> BatchResult:
|
| 120 |
+
"""
|
| 121 |
+
Generate one asset per theme.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
pipeline: Pipeline name
|
| 125 |
+
themes: List of theme descriptions
|
| 126 |
+
"""
|
| 127 |
+
batch = BatchResult(
|
| 128 |
+
batch_name=f"{pipeline}_themes_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
| 129 |
+
pipeline=pipeline,
|
| 130 |
+
total=len(themes),
|
| 131 |
+
succeeded=0,
|
| 132 |
+
failed=0,
|
| 133 |
+
started_at=datetime.now().isoformat(),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
for i, theme in enumerate(themes):
|
| 137 |
+
inputs = {"prompt": theme, **kwargs}
|
| 138 |
+
run = self.orchestrator.run(pipeline, inputs=inputs, dry_run=dry_run)
|
| 139 |
+
batch.runs.append(run)
|
| 140 |
+
|
| 141 |
+
if run.success:
|
| 142 |
+
batch.succeeded += 1
|
| 143 |
+
else:
|
| 144 |
+
batch.failed += 1
|
| 145 |
+
|
| 146 |
+
logger.info(f"Theme {i+1}/{len(themes)}: {'OK' if run.success else 'FAIL'}")
|
| 147 |
+
|
| 148 |
+
batch.completed_at = datetime.now().isoformat()
|
| 149 |
+
return batch
|
| 150 |
+
|
| 151 |
+
def generate_asset_pack_batch(
|
| 152 |
+
self,
|
| 153 |
+
themes: List[str],
|
| 154 |
+
dry_run: bool = False,
|
| 155 |
+
) -> BatchResult:
|
| 156 |
+
"""
|
| 157 |
+
Generate multiple complete asset packs, one per theme.
|
| 158 |
+
"""
|
| 159 |
+
return self.generate_from_themes("asset_pack", themes, dry_run=dry_run)
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
def _default_variation(prompt: str, index: int) -> str:
|
| 163 |
+
"""Create default prompt variations."""
|
| 164 |
+
styles = [
|
| 165 |
+
"",
|
| 166 |
+
", highly detailed",
|
| 167 |
+
", stylized, painterly",
|
| 168 |
+
", photorealistic",
|
| 169 |
+
", pixel art style",
|
| 170 |
+
", low poly",
|
| 171 |
+
]
|
| 172 |
+
style = styles[index % len(styles)]
|
| 173 |
+
return f"{prompt}{style}"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def get_batch_generator(output_dir: Optional[str] = None) -> BatchGenerator:
|
| 177 |
+
"""Get a BatchGenerator instance."""
|
| 178 |
+
return BatchGenerator(output_dir)
|
gameforge/engine/browser.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Asset Browser - Browse, search, and manage generated assets.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Optional, Dict, Any, List
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
STORAGE_DIR = Path(__file__).parent.parent / "storage" / "local"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class AssetInfo:
|
| 17 |
+
"""Information about a generated asset."""
|
| 18 |
+
path: str
|
| 19 |
+
name: str
|
| 20 |
+
asset_type: str
|
| 21 |
+
size_bytes: int
|
| 22 |
+
created: str
|
| 23 |
+
format: str
|
| 24 |
+
|
| 25 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 26 |
+
return {
|
| 27 |
+
"path": self.path,
|
| 28 |
+
"name": self.name,
|
| 29 |
+
"asset_type": self.asset_type,
|
| 30 |
+
"size": self.size_bytes,
|
| 31 |
+
"size_human": self._human_size(self.size_bytes),
|
| 32 |
+
"created": self.created,
|
| 33 |
+
"format": self.format,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
def _human_size(n: int) -> str:
|
| 38 |
+
for unit in ["B", "KB", "MB", "GB"]:
|
| 39 |
+
if n < 1024:
|
| 40 |
+
return f"{n:.1f} {unit}"
|
| 41 |
+
n /= 1024
|
| 42 |
+
return f"{n:.1f} TB"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class AssetBrowser:
|
| 46 |
+
"""
|
| 47 |
+
Browse and manage generated game assets.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, storage_dir: Optional[str] = None):
|
| 51 |
+
self.storage_dir = Path(storage_dir) if storage_dir else STORAGE_DIR
|
| 52 |
+
|
| 53 |
+
def list_assets(
|
| 54 |
+
self,
|
| 55 |
+
asset_type: Optional[str] = None,
|
| 56 |
+
format_filter: Optional[str] = None,
|
| 57 |
+
sort_by: str = "created",
|
| 58 |
+
) -> List[AssetInfo]:
|
| 59 |
+
"""
|
| 60 |
+
List all generated assets.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
asset_type: Filter by subfolder (characters, voices, etc.)
|
| 64 |
+
format_filter: Filter by file extension (.png, .wav, etc.)
|
| 65 |
+
sort_by: Sort by "name", "created", or "size"
|
| 66 |
+
"""
|
| 67 |
+
assets = []
|
| 68 |
+
search_dir = self.storage_dir / asset_type if asset_type else self.storage_dir
|
| 69 |
+
|
| 70 |
+
if not search_dir.exists():
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
for path in search_dir.rglob("*"):
|
| 74 |
+
if not path.is_file():
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
if format_filter and not path.suffix.lower() == format_filter.lower():
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
stat = path.stat()
|
| 81 |
+
rel_path = path.relative_to(self.storage_dir)
|
| 82 |
+
type_folder = rel_path.parts[0] if len(rel_path.parts) > 1 else "root"
|
| 83 |
+
|
| 84 |
+
assets.append(AssetInfo(
|
| 85 |
+
path=str(path),
|
| 86 |
+
name=path.name,
|
| 87 |
+
asset_type=type_folder,
|
| 88 |
+
size_bytes=stat.st_size,
|
| 89 |
+
created=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
| 90 |
+
format=path.suffix.lower(),
|
| 91 |
+
))
|
| 92 |
+
|
| 93 |
+
if sort_by == "name":
|
| 94 |
+
assets.sort(key=lambda a: a.name)
|
| 95 |
+
elif sort_by == "size":
|
| 96 |
+
assets.sort(key=lambda a: a.size_bytes, reverse=True)
|
| 97 |
+
else:
|
| 98 |
+
assets.sort(key=lambda a: a.created, reverse=True)
|
| 99 |
+
|
| 100 |
+
return assets
|
| 101 |
+
|
| 102 |
+
def list_types(self) -> Dict[str, int]:
|
| 103 |
+
"""List asset types with counts."""
|
| 104 |
+
types = {}
|
| 105 |
+
if not self.storage_dir.exists():
|
| 106 |
+
return types
|
| 107 |
+
|
| 108 |
+
for subdir in self.storage_dir.iterdir():
|
| 109 |
+
if subdir.is_dir():
|
| 110 |
+
count = sum(1 for f in subdir.rglob("*") if f.is_file())
|
| 111 |
+
if count > 0:
|
| 112 |
+
types[subdir.name] = count
|
| 113 |
+
return types
|
| 114 |
+
|
| 115 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 116 |
+
"""Get storage statistics."""
|
| 117 |
+
if not self.storage_dir.exists():
|
| 118 |
+
return {"total_files": 0, "total_size": 0, "types": {}}
|
| 119 |
+
|
| 120 |
+
types = self.list_types()
|
| 121 |
+
total_files = sum(types.values())
|
| 122 |
+
total_size = sum(
|
| 123 |
+
f.stat().st_size
|
| 124 |
+
for f in self.storage_dir.rglob("*")
|
| 125 |
+
if f.is_file()
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"total_files": total_files,
|
| 130 |
+
"total_size": total_size,
|
| 131 |
+
"total_size_human": AssetInfo._human_size(total_size),
|
| 132 |
+
"types": types,
|
| 133 |
+
"storage_path": str(self.storage_dir),
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
def search(self, query: str) -> List[AssetInfo]:
|
| 137 |
+
"""Search assets by name."""
|
| 138 |
+
return [
|
| 139 |
+
a for a in self.list_assets()
|
| 140 |
+
if query.lower() in a.name.lower()
|
| 141 |
+
]
|
| 142 |
+
|
| 143 |
+
def delete(self, path: str) -> bool:
|
| 144 |
+
"""Delete an asset file."""
|
| 145 |
+
p = Path(path)
|
| 146 |
+
if p.exists() and p.is_file() and p.is_relative_to(self.storage_dir):
|
| 147 |
+
p.unlink()
|
| 148 |
+
return True
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
def clear_type(self, asset_type: str) -> int:
|
| 152 |
+
"""Clear all assets of a type. Returns count deleted."""
|
| 153 |
+
type_dir = self.storage_dir / asset_type
|
| 154 |
+
if not type_dir.exists():
|
| 155 |
+
return 0
|
| 156 |
+
count = 0
|
| 157 |
+
for f in type_dir.rglob("*"):
|
| 158 |
+
if f.is_file():
|
| 159 |
+
f.unlink()
|
| 160 |
+
count += 1
|
| 161 |
+
return count
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def get_browser(storage_dir: Optional[str] = None) -> AssetBrowser:
|
| 165 |
+
"""Get an AssetBrowser instance."""
|
| 166 |
+
return AssetBrowser(storage_dir)
|
gameforge/engine/converter.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Converter - Converts assets between formats for game engine compatibility.
|
| 3 |
+
Handles images, 3D models, audio, and video formats.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import subprocess
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Optional, Dict, Any, List
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# Format conversion map: (source_ext, target_ext) -> converter function name
|
| 15 |
+
CONVERSION_MAP = {
|
| 16 |
+
# Image conversions
|
| 17 |
+
(".png", ".jpg"): "convert_image",
|
| 18 |
+
(".png", ".webp"): "convert_image",
|
| 19 |
+
(".png", ".tga"): "convert_image",
|
| 20 |
+
(".jpg", ".png"): "convert_image",
|
| 21 |
+
(".webp", ".png"): "convert_image",
|
| 22 |
+
(".bmp", ".png"): "convert_image",
|
| 23 |
+
|
| 24 |
+
# Audio conversions
|
| 25 |
+
(".wav", ".ogg"): "convert_audio",
|
| 26 |
+
(".wav", ".mp3"): "convert_audio",
|
| 27 |
+
(".wav", ".flac"): "convert_audio",
|
| 28 |
+
(".mp3", ".ogg"): "convert_audio",
|
| 29 |
+
(".mp3", ".wav"): "convert_audio",
|
| 30 |
+
(".ogg", ".wav"): "convert_audio",
|
| 31 |
+
|
| 32 |
+
# Video conversions
|
| 33 |
+
(".mp4", ".webm"): "convert_video",
|
| 34 |
+
(".webm", ".mp4"): "convert_video",
|
| 35 |
+
(".avi", ".mp4"): "convert_video",
|
| 36 |
+
|
| 37 |
+
# 3D model conversions
|
| 38 |
+
(".glb", ".fbx"): "convert_3d",
|
| 39 |
+
(".glb", ".obj"): "convert_3d",
|
| 40 |
+
(".gltf", ".glb"): "convert_3d",
|
| 41 |
+
(".obj", ".glb"): "convert_3d",
|
| 42 |
+
(".fbx", ".glb"): "convert_3d",
|
| 43 |
+
(".stl", ".glb"): "convert_3d",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Game engine specific export presets
|
| 48 |
+
ENGINE_PRESETS = {
|
| 49 |
+
"unity": {
|
| 50 |
+
"image": {".png": {"max_size": 4096}, ".jpg": {"quality": 90}},
|
| 51 |
+
"3d": {".fbx": {"scale": 1.0}},
|
| 52 |
+
"audio": {".ogg": {"quality": 6}},
|
| 53 |
+
"video": {".mp4": {"codec": "h264"}},
|
| 54 |
+
},
|
| 55 |
+
"unreal": {
|
| 56 |
+
"image": {".png": {"max_size": 4096}, ".tga": {}},
|
| 57 |
+
"3d": {".fbx": {"scale": 1.0}},
|
| 58 |
+
"audio": {".wav": {"sample_rate": 44100}},
|
| 59 |
+
"video": {".mp4": {"codec": "h264"}},
|
| 60 |
+
},
|
| 61 |
+
"godot": {
|
| 62 |
+
"image": {".png": {"max_size": 4096}, ".webp": {"quality": 85}},
|
| 63 |
+
"3d": {".glb": {}},
|
| 64 |
+
"audio": {".ogg": {"quality": 6}, ".wav": {}},
|
| 65 |
+
"video": {".webm": {"codec": "vp9"}},
|
| 66 |
+
},
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def convert_asset(
|
| 71 |
+
input_path: str,
|
| 72 |
+
target_format: str,
|
| 73 |
+
engine: Optional[str] = None,
|
| 74 |
+
**kwargs,
|
| 75 |
+
) -> Optional[str]:
|
| 76 |
+
"""
|
| 77 |
+
Convert an asset to a target format.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
input_path: Path to the input file
|
| 81 |
+
target_format: Target format (e.g., ".png", ".ogg", ".fbx", ".glb")
|
| 82 |
+
engine: Optional game engine name ("unity", "unreal", "godot")
|
| 83 |
+
Returns:
|
| 84 |
+
Path to the converted file, or None on failure
|
| 85 |
+
"""
|
| 86 |
+
inp = Path(input_path)
|
| 87 |
+
if not inp.exists():
|
| 88 |
+
logger.error(f"Input file not found: {input_path}")
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
src_ext = inp.suffix.lower()
|
| 92 |
+
tgt_ext = target_format.lower() if target_format.startswith(".") else f".{target_format}"
|
| 93 |
+
|
| 94 |
+
# No conversion needed
|
| 95 |
+
if src_ext == tgt_ext:
|
| 96 |
+
return str(inp)
|
| 97 |
+
|
| 98 |
+
# Find converter
|
| 99 |
+
converter_name = CONVERSION_MAP.get((src_ext, tgt_ext))
|
| 100 |
+
if not converter_name:
|
| 101 |
+
# Try same-family conversion (e.g., image->image)
|
| 102 |
+
converter_name = _infer_converter(src_ext, tgt_ext)
|
| 103 |
+
|
| 104 |
+
if not converter_name:
|
| 105 |
+
logger.error(f"No converter for {src_ext} -> {tgt_ext}")
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
converter_fn = CONVERTERS.get(converter_name)
|
| 109 |
+
if not converter_fn:
|
| 110 |
+
logger.error(f"Converter not implemented: {converter_name}")
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
# Build output path
|
| 114 |
+
output_path = inp.with_suffix(tgt_ext)
|
| 115 |
+
|
| 116 |
+
# Apply engine preset if specified
|
| 117 |
+
if engine and engine in ENGINE_PRESETS:
|
| 118 |
+
engine_type = _guess_asset_type(tgt_ext)
|
| 119 |
+
preset = ENGINE_PRESETS.get(engine, {}).get(engine_type, {}).get(tgt_ext, {})
|
| 120 |
+
kwargs.update(preset)
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
result = converter_fn(str(inp), str(output_path), **kwargs)
|
| 124 |
+
if result:
|
| 125 |
+
logger.info(f"Converted: {inp.name} -> {output_path.name}")
|
| 126 |
+
return result
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Conversion failed: {e}")
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def export_for_engine(
|
| 133 |
+
input_path: str,
|
| 134 |
+
engine: str,
|
| 135 |
+
**kwargs,
|
| 136 |
+
) -> Optional[str]:
|
| 137 |
+
"""
|
| 138 |
+
Export an asset in the best format for a game engine.
|
| 139 |
+
|
| 140 |
+
Args:
|
| 141 |
+
input_path: Path to the input file
|
| 142 |
+
engine: "unity", "unreal", or "godot"
|
| 143 |
+
"""
|
| 144 |
+
inp = Path(input_path)
|
| 145 |
+
asset_type = _guess_asset_type(inp.suffix)
|
| 146 |
+
preset = ENGINE_PRESETS.get(engine, {}).get(asset_type, {})
|
| 147 |
+
|
| 148 |
+
if not preset:
|
| 149 |
+
logger.warning(f"No preset for {engine}/{asset_type}, keeping original")
|
| 150 |
+
return str(inp)
|
| 151 |
+
|
| 152 |
+
# Pick the first (preferred) format
|
| 153 |
+
target_ext = list(preset.keys())[0]
|
| 154 |
+
return convert_asset(input_path, target_ext, engine=engine)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def batch_convert(
|
| 158 |
+
input_dir: str,
|
| 159 |
+
target_format: str,
|
| 160 |
+
engine: Optional[str] = None,
|
| 161 |
+
pattern: str = "*",
|
| 162 |
+
) -> List[str]:
|
| 163 |
+
"""Convert all matching files in a directory."""
|
| 164 |
+
results = []
|
| 165 |
+
for f in Path(input_dir).glob(pattern):
|
| 166 |
+
result = convert_asset(str(f), target_format, engine=engine)
|
| 167 |
+
if result:
|
| 168 |
+
results.append(result)
|
| 169 |
+
return results
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# === Individual Converters ===
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def convert_image(src: str, dst: str, quality: int = 95, max_size: int = 0, **kw) -> Optional[str]:
|
| 176 |
+
"""Convert image using Pillow."""
|
| 177 |
+
try:
|
| 178 |
+
from PIL import Image
|
| 179 |
+
img = Image.open(src)
|
| 180 |
+
if img.mode == "RGBA" and dst.endswith((".jpg",)):
|
| 181 |
+
img = img.convert("RGB")
|
| 182 |
+
if max_size > 0:
|
| 183 |
+
img.thumbnail((max_size, max_size), Image.LANCZOS)
|
| 184 |
+
save_kw = {}
|
| 185 |
+
if dst.endswith(".jpg") or dst.endswith(".jpeg"):
|
| 186 |
+
save_kw["quality"] = quality
|
| 187 |
+
elif dst.endswith(".webp"):
|
| 188 |
+
save_kw["quality"] = quality
|
| 189 |
+
img.save(dst, **save_kw)
|
| 190 |
+
return dst
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logger.error(f"Image conversion failed: {e}")
|
| 193 |
+
return None
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def convert_audio(src: str, dst: str, sample_rate: int = 44100, quality: int = 5, **kw) -> Optional[str]:
|
| 197 |
+
"""Convert audio using ffmpeg."""
|
| 198 |
+
try:
|
| 199 |
+
cmd = ["ffmpeg", "-y", "-i", src]
|
| 200 |
+
if dst.endswith(".ogg"):
|
| 201 |
+
cmd += ["-codec:a", "libvorbis", "-qscale:a", str(quality)]
|
| 202 |
+
elif dst.endswith(".mp3"):
|
| 203 |
+
cmd += ["-codec:a", "libmp3lame", "-qscale:a", str(quality)]
|
| 204 |
+
elif dst.endswith(".wav"):
|
| 205 |
+
cmd += ["-ar", str(sample_rate)]
|
| 206 |
+
elif dst.endswith(".flac"):
|
| 207 |
+
cmd += ["-codec:a", "flac"]
|
| 208 |
+
cmd.append(dst)
|
| 209 |
+
result = subprocess.run(cmd, capture_output=True, timeout=300)
|
| 210 |
+
if result.returncode == 0:
|
| 211 |
+
return dst
|
| 212 |
+
logger.error(f"ffmpeg error: {result.stderr.decode()[:200]}")
|
| 213 |
+
return None
|
| 214 |
+
except FileNotFoundError:
|
| 215 |
+
logger.error("ffmpeg not installed")
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def convert_video(src: str, dst: str, codec: str = "h264", **kw) -> Optional[str]:
|
| 220 |
+
"""Convert video using ffmpeg."""
|
| 221 |
+
try:
|
| 222 |
+
cmd = ["ffmpeg", "-y", "-i", src]
|
| 223 |
+
if codec == "h264":
|
| 224 |
+
cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", "23"]
|
| 225 |
+
elif codec == "vp9":
|
| 226 |
+
cmd += ["-c:v", "libvpx-v9", "-crf", "30", "-b:v", "0"]
|
| 227 |
+
cmd.append(dst)
|
| 228 |
+
result = subprocess.run(cmd, capture_output=True, timeout=600)
|
| 229 |
+
if result.returncode == 0:
|
| 230 |
+
return dst
|
| 231 |
+
return None
|
| 232 |
+
except FileNotFoundError:
|
| 233 |
+
logger.error("ffmpeg not installed")
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def convert_3d(src: str, dst: str, scale: float = 1.0, **kw) -> Optional[str]:
|
| 238 |
+
"""Convert 3D models using trimesh."""
|
| 239 |
+
try:
|
| 240 |
+
import trimesh
|
| 241 |
+
mesh = trimesh.load(src)
|
| 242 |
+
if scale != 1.0 and hasattr(mesh, 'vertices'):
|
| 243 |
+
mesh.vertices *= scale
|
| 244 |
+
mesh.export(dst)
|
| 245 |
+
return dst
|
| 246 |
+
except ImportError:
|
| 247 |
+
logger.error("trimesh not installed. pip install trimesh")
|
| 248 |
+
return None
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"3D conversion failed: {e}")
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# === Helpers ===
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _infer_converter(src_ext: str, tgt_ext: str) -> Optional[str]:
|
| 258 |
+
"""Infer converter from extension families."""
|
| 259 |
+
image_exts = {".png", ".jpg", ".jpeg", ".webp", ".tga", ".bmp", ".gif"}
|
| 260 |
+
audio_exts = {".wav", ".mp3", ".ogg", ".flac", ".aac", ".m4a"}
|
| 261 |
+
video_exts = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
|
| 262 |
+
threed_exts = {".glb", ".gltf", ".obj", ".fbx", ".stl", ".ply"}
|
| 263 |
+
|
| 264 |
+
if src_ext in image_exts and tgt_ext in image_exts:
|
| 265 |
+
return "convert_image"
|
| 266 |
+
if src_ext in audio_exts and tgt_ext in audio_exts:
|
| 267 |
+
return "convert_audio"
|
| 268 |
+
if src_ext in video_exts and tgt_ext in video_exts:
|
| 269 |
+
return "convert_video"
|
| 270 |
+
if src_ext in threed_exts and tgt_ext in threed_exts:
|
| 271 |
+
return "convert_3d"
|
| 272 |
+
return None
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _guess_asset_type(ext: str) -> str:
|
| 276 |
+
"""Guess asset type from extension."""
|
| 277 |
+
image_exts = {".png", ".jpg", ".jpeg", ".webp", ".tga", ".bmp"}
|
| 278 |
+
audio_exts = {".wav", ".mp3", ".ogg", ".flac"}
|
| 279 |
+
video_exts = {".mp4", ".webm", ".avi", ".mov"}
|
| 280 |
+
threed_exts = {".glb", ".gltf", ".obj", ".fbx", ".stl"}
|
| 281 |
+
|
| 282 |
+
ext = ext.lower()
|
| 283 |
+
if ext in image_exts:
|
| 284 |
+
return "image"
|
| 285 |
+
if ext in audio_exts:
|
| 286 |
+
return "audio"
|
| 287 |
+
if ext in video_exts:
|
| 288 |
+
return "video"
|
| 289 |
+
if ext in threed_exts:
|
| 290 |
+
return "3d"
|
| 291 |
+
return "unknown"
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
CONVERTERS = {
|
| 295 |
+
"convert_image": convert_image,
|
| 296 |
+
"convert_audio": convert_audio,
|
| 297 |
+
"convert_video": convert_video,
|
| 298 |
+
"convert_3d": convert_3d,
|
| 299 |
+
}
|
gameforge/engine/orchestrator.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Orchestrator - Runs pipeline YAML definitions through the HF Harness.
|
| 3 |
+
Handles DAG execution, retries, and state management.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import yaml
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import os
|
| 11 |
+
import logging
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional, Dict, Any, List, Callable
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
|
| 17 |
+
from gameforge.config.registry_loader import get_registry, ModelEntry
|
| 18 |
+
from gameforge.engine.router import Router, RouteDecision, ExecutionTarget
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
PIPELINES_DIR = Path(__file__).parent.parent / "pipelines"
|
| 23 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "storage" / "local"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class StepResult:
|
| 28 |
+
"""Result of a single pipeline step."""
|
| 29 |
+
step_name: str
|
| 30 |
+
success: bool
|
| 31 |
+
output: Any = None
|
| 32 |
+
error: Optional[str] = None
|
| 33 |
+
duration_sec: float = 0.0
|
| 34 |
+
model_used: str = ""
|
| 35 |
+
execution_target: str = ""
|
| 36 |
+
|
| 37 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 38 |
+
return {
|
| 39 |
+
"step": self.step_name,
|
| 40 |
+
"success": self.success,
|
| 41 |
+
"output": str(self.output)[:200] if self.output else None,
|
| 42 |
+
"error": self.error,
|
| 43 |
+
"duration": self.duration_sec,
|
| 44 |
+
"model": self.model_used,
|
| 45 |
+
"target": self.execution_target,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class PipelineRun:
|
| 51 |
+
"""A single execution of a pipeline."""
|
| 52 |
+
pipeline_name: str
|
| 53 |
+
started_at: str = ""
|
| 54 |
+
completed_at: str = ""
|
| 55 |
+
steps: List[StepResult] = field(default_factory=list)
|
| 56 |
+
inputs: Dict[str, Any] = field(default_factory=dict)
|
| 57 |
+
outputs: Dict[str, Any] = field(default_factory=dict)
|
| 58 |
+
status: str = "pending" # pending, running, completed, failed
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def success(self) -> bool:
|
| 62 |
+
return self.status == "completed" and all(s.success for s in self.steps)
|
| 63 |
+
|
| 64 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 65 |
+
return {
|
| 66 |
+
"pipeline": self.pipeline_name,
|
| 67 |
+
"status": self.status,
|
| 68 |
+
"started": self.started_at,
|
| 69 |
+
"completed": self.completed_at,
|
| 70 |
+
"steps": [s.to_dict() for s in self.steps],
|
| 71 |
+
"outputs": {k: str(v)[:200] for k, v in self.outputs.items()},
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class Orchestrator:
|
| 76 |
+
"""
|
| 77 |
+
Executes pipeline definitions by routing each step through HF Harness.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
def __init__(self, output_dir: Optional[str] = None):
|
| 81 |
+
self.registry = get_registry()
|
| 82 |
+
self.router = Router()
|
| 83 |
+
self.output_dir = Path(output_dir) if output_dir else OUTPUT_DIR
|
| 84 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
self._history: List[PipelineRun] = []
|
| 86 |
+
|
| 87 |
+
def load_pipeline(self, name: str) -> Dict[str, Any]:
|
| 88 |
+
"""Load a pipeline YAML definition."""
|
| 89 |
+
# Try exact name first, then with .yaml extension
|
| 90 |
+
candidates = [
|
| 91 |
+
PIPELINES_DIR / f"{name}.yaml",
|
| 92 |
+
PIPELINES_DIR / name,
|
| 93 |
+
PIPELINES_DIR / f"{name}.yml",
|
| 94 |
+
]
|
| 95 |
+
for path in candidates:
|
| 96 |
+
if path.exists():
|
| 97 |
+
with open(path) as f:
|
| 98 |
+
data = yaml.safe_load(f)
|
| 99 |
+
data["_path"] = str(path)
|
| 100 |
+
return data
|
| 101 |
+
raise FileNotFoundError(f"Pipeline not found: {name} (searched {PIPELINES_DIR})")
|
| 102 |
+
|
| 103 |
+
def list_pipelines(self) -> List[Dict[str, str]]:
|
| 104 |
+
"""List available pipeline definitions."""
|
| 105 |
+
result = []
|
| 106 |
+
for path in PIPELINES_DIR.glob("*.yaml"):
|
| 107 |
+
try:
|
| 108 |
+
with open(path) as f:
|
| 109 |
+
data = yaml.safe_load(f)
|
| 110 |
+
result.append({
|
| 111 |
+
"name": path.stem,
|
| 112 |
+
"description": data.get("description", ""),
|
| 113 |
+
"version": data.get("version", ""),
|
| 114 |
+
"steps": len(data.get("steps", [])),
|
| 115 |
+
})
|
| 116 |
+
except Exception:
|
| 117 |
+
pass
|
| 118 |
+
return result
|
| 119 |
+
|
| 120 |
+
def run(
|
| 121 |
+
self,
|
| 122 |
+
pipeline_name: str,
|
| 123 |
+
inputs: Optional[Dict[str, Any]] = None,
|
| 124 |
+
dry_run: bool = False,
|
| 125 |
+
) -> PipelineRun:
|
| 126 |
+
"""
|
| 127 |
+
Execute a pipeline end-to-end.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
pipeline_name: Name of the pipeline YAML (without extension)
|
| 131 |
+
inputs: Override default inputs
|
| 132 |
+
dry_run: If True, show what would happen without executing
|
| 133 |
+
"""
|
| 134 |
+
pipeline = self.load_pipeline(pipeline_name)
|
| 135 |
+
run = PipelineRun(
|
| 136 |
+
pipeline_name=pipeline_name,
|
| 137 |
+
started_at=datetime.now().isoformat(),
|
| 138 |
+
inputs=inputs or {},
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Merge defaults with provided inputs
|
| 142 |
+
defaults = pipeline.get("defaults", {})
|
| 143 |
+
run.inputs = {**defaults, **(inputs or {})}
|
| 144 |
+
|
| 145 |
+
logger.info(f"Starting pipeline: {pipeline_name}")
|
| 146 |
+
run.status = "running"
|
| 147 |
+
|
| 148 |
+
if dry_run:
|
| 149 |
+
run = self._dry_run(pipeline, run)
|
| 150 |
+
run.status = "completed"
|
| 151 |
+
return run
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
context = dict(pipeline.get("defaults", {}))
|
| 155 |
+
context.update(run.inputs) # Step outputs accumulate here
|
| 156 |
+
|
| 157 |
+
for step_def in pipeline.get("steps", []):
|
| 158 |
+
step_result = self._execute_step(step_def, context)
|
| 159 |
+
run.steps.append(step_result)
|
| 160 |
+
|
| 161 |
+
if step_result.success and step_result.output is not None:
|
| 162 |
+
context[step_def["name"]] = step_result.output
|
| 163 |
+
# Also store by output_key if specified
|
| 164 |
+
out_key = step_def.get("output_key")
|
| 165 |
+
if out_key:
|
| 166 |
+
context[out_key] = step_result.output
|
| 167 |
+
elif not step_result.success:
|
| 168 |
+
# Check if step is optional
|
| 169 |
+
if step_def.get("optional", False):
|
| 170 |
+
logger.warning(f"Optional step {step_def['name']} failed, continuing")
|
| 171 |
+
else:
|
| 172 |
+
run.status = "failed"
|
| 173 |
+
break
|
| 174 |
+
|
| 175 |
+
if run.status != "failed":
|
| 176 |
+
run.status = "completed"
|
| 177 |
+
|
| 178 |
+
# Collect outputs
|
| 179 |
+
output_keys = pipeline.get("outputs", [])
|
| 180 |
+
for key in output_keys:
|
| 181 |
+
if key in context:
|
| 182 |
+
run.outputs[key] = context[key]
|
| 183 |
+
|
| 184 |
+
except Exception as e:
|
| 185 |
+
logger.error(f"Pipeline failed: {e}")
|
| 186 |
+
run.status = "failed"
|
| 187 |
+
run.steps.append(StepResult(
|
| 188 |
+
step_name="pipeline_error",
|
| 189 |
+
success=False,
|
| 190 |
+
error=str(e),
|
| 191 |
+
))
|
| 192 |
+
|
| 193 |
+
run.completed_at = datetime.now().isoformat()
|
| 194 |
+
self._history.append(run)
|
| 195 |
+
return run
|
| 196 |
+
|
| 197 |
+
def _dry_run(self, pipeline: Dict[str, Any], run: PipelineRun) -> PipelineRun:
|
| 198 |
+
"""Show what would be executed without running."""
|
| 199 |
+
for step_def in pipeline.get("steps", []):
|
| 200 |
+
step_type = step_def.get("type", "unknown")
|
| 201 |
+
asset_type = step_def.get("asset_type", "")
|
| 202 |
+
variant = step_def.get("variant", "primary")
|
| 203 |
+
|
| 204 |
+
route = self.router.route(asset_type, variant) if asset_type else None
|
| 205 |
+
run.steps.append(StepResult(
|
| 206 |
+
step_name=step_def["name"],
|
| 207 |
+
success=True,
|
| 208 |
+
output=f"[DRY RUN] Would execute {step_type} via {route.target.value if route else 'unknown'}",
|
| 209 |
+
model_used=route.model_entry.model if route else "unknown",
|
| 210 |
+
execution_target=route.target.value if route else "unknown",
|
| 211 |
+
))
|
| 212 |
+
return run
|
| 213 |
+
|
| 214 |
+
def _execute_step(self, step_def: Dict[str, Any], context: Dict[str, Any]) -> StepResult:
|
| 215 |
+
"""Execute a single pipeline step."""
|
| 216 |
+
step_name = step_def["name"]
|
| 217 |
+
step_type = step_def.get("type", "unknown")
|
| 218 |
+
start_time = time.time()
|
| 219 |
+
|
| 220 |
+
logger.info(f" Executing step: {step_name} (type={step_type})")
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
if step_type == "generate":
|
| 224 |
+
return self._step_generate(step_def, context, start_time)
|
| 225 |
+
elif step_type == "transform":
|
| 226 |
+
return self._step_transform(step_def, context, start_time)
|
| 227 |
+
elif step_type == "enhance_prompt":
|
| 228 |
+
return self._step_enhance_prompt(step_def, context, start_time)
|
| 229 |
+
elif step_type == "save":
|
| 230 |
+
return self._step_save(step_def, context, start_time)
|
| 231 |
+
elif step_type == "validate":
|
| 232 |
+
return self._step_validate(step_def, context, start_time)
|
| 233 |
+
elif step_type == "convert":
|
| 234 |
+
return self._step_convert(step_def, context, start_time)
|
| 235 |
+
elif step_type == "call_space":
|
| 236 |
+
return self._step_call_space(step_def, context, start_time)
|
| 237 |
+
else:
|
| 238 |
+
return StepResult(
|
| 239 |
+
step_name=step_name,
|
| 240 |
+
success=False,
|
| 241 |
+
error=f"Unknown step type: {step_type}",
|
| 242 |
+
duration_sec=time.time() - start_time,
|
| 243 |
+
)
|
| 244 |
+
except Exception as e:
|
| 245 |
+
return StepResult(
|
| 246 |
+
step_name=step_name,
|
| 247 |
+
success=False,
|
| 248 |
+
error=str(e),
|
| 249 |
+
duration_sec=time.time() - start_time,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
def _resolve_input(self, input_spec: Any, context: Dict[str, Any]) -> Any:
|
| 253 |
+
"""Resolve a step input -- can be literal value or context reference."""
|
| 254 |
+
if isinstance(input_spec, str) and input_spec.startswith("$"):
|
| 255 |
+
key = input_spec[1:]
|
| 256 |
+
return context.get(key, input_spec)
|
| 257 |
+
return input_spec
|
| 258 |
+
|
| 259 |
+
def _step_generate(self, step: Dict, context: Dict, start: float) -> StepResult:
|
| 260 |
+
"""Generate asset using a model."""
|
| 261 |
+
asset_type = step["asset_type"]
|
| 262 |
+
variant_raw = step.get("variant", "primary")
|
| 263 |
+
variant = self._resolve_input(variant_raw, context)
|
| 264 |
+
# Ensure variant is a string (not a resolved dict/object)
|
| 265 |
+
if not isinstance(variant, str):
|
| 266 |
+
variant = "primary"
|
| 267 |
+
prompt = self._resolve_input(step.get("prompt", ""), context)
|
| 268 |
+
|
| 269 |
+
route = self.router.route(asset_type, variant)
|
| 270 |
+
|
| 271 |
+
if route.target == ExecutionTarget.UNSUPPORTED:
|
| 272 |
+
return StepResult(
|
| 273 |
+
step_name=step["name"],
|
| 274 |
+
success=False,
|
| 275 |
+
error=route.reason,
|
| 276 |
+
duration_sec=time.time() - start,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# Build params from registry + step overrides
|
| 280 |
+
params = dict(route.model_entry.params)
|
| 281 |
+
params.update(step.get("params", {}))
|
| 282 |
+
params["prompt"] = prompt
|
| 283 |
+
|
| 284 |
+
# Route to appropriate executor
|
| 285 |
+
if route.target == ExecutionTarget.HF_INFERENCE:
|
| 286 |
+
output = self._exec_inference(route.model_entry, params)
|
| 287 |
+
elif route.target == ExecutionTarget.HF_SPACE:
|
| 288 |
+
output = self._exec_space(route.model_entry, params)
|
| 289 |
+
elif route.target == ExecutionTarget.LOCAL_PIPELINE:
|
| 290 |
+
output = self._exec_local_pipeline(route.model_entry, params)
|
| 291 |
+
else:
|
| 292 |
+
output = f"[{route.target.value}] Execution delegated"
|
| 293 |
+
|
| 294 |
+
return StepResult(
|
| 295 |
+
step_name=step["name"],
|
| 296 |
+
success=True,
|
| 297 |
+
output=output,
|
| 298 |
+
duration_sec=time.time() - start,
|
| 299 |
+
model_used=route.model_entry.model,
|
| 300 |
+
execution_target=route.target.value,
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
def _step_enhance_prompt(self, step: Dict, context: Dict, start: float) -> StepResult:
|
| 304 |
+
"""Enhance a prompt using an LLM."""
|
| 305 |
+
prompt = self._resolve_input(step.get("prompt", ""), context)
|
| 306 |
+
template = step.get("template", "Enhance this game asset description for AI generation: {prompt}")
|
| 307 |
+
|
| 308 |
+
enhanced_prompt = template.format(prompt=prompt)
|
| 309 |
+
|
| 310 |
+
# Try the utility prompt enhancer model, but fall back gracefully
|
| 311 |
+
enhancer = self.registry.get_utility("prompt_enhancer")
|
| 312 |
+
if enhancer:
|
| 313 |
+
try:
|
| 314 |
+
from huggingface_hub import InferenceClient
|
| 315 |
+
import os
|
| 316 |
+
token = os.environ.get("HF_TOKEN", "")
|
| 317 |
+
client = InferenceClient(token=token)
|
| 318 |
+
messages = [{"role": "user", "content": enhanced_prompt}]
|
| 319 |
+
response = client.chat.completions.create(
|
| 320 |
+
model=enhancer.model,
|
| 321 |
+
messages=messages,
|
| 322 |
+
max_tokens=enhancer.params.get("max_tokens", 512),
|
| 323 |
+
temperature=enhancer.params.get("temperature", 0.8),
|
| 324 |
+
)
|
| 325 |
+
result = response.choices[0].message.content
|
| 326 |
+
return StepResult(
|
| 327 |
+
step_name=step["name"],
|
| 328 |
+
success=True,
|
| 329 |
+
output=result,
|
| 330 |
+
duration_sec=time.time() - start,
|
| 331 |
+
model_used=enhancer.model,
|
| 332 |
+
execution_target="hf_inference",
|
| 333 |
+
)
|
| 334 |
+
except Exception as e:
|
| 335 |
+
logger.warning(f"Prompt enhancer failed ({e}), using template result")
|
| 336 |
+
|
| 337 |
+
# Fallback: just return the template result
|
| 338 |
+
return StepResult(
|
| 339 |
+
step_name=step["name"],
|
| 340 |
+
success=True,
|
| 341 |
+
output=enhanced_prompt,
|
| 342 |
+
duration_sec=time.time() - start,
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
def _step_transform(self, step: Dict, context: Dict, start: float) -> StepResult:
|
| 346 |
+
"""Transform data using a function."""
|
| 347 |
+
fn_name = step.get("function", "")
|
| 348 |
+
input_val = self._resolve_input(step.get("input", ""), context)
|
| 349 |
+
|
| 350 |
+
# Built-in transform functions
|
| 351 |
+
transforms = {
|
| 352 |
+
"prefix": lambda x, p=step.get("prefix", ""): f"{p}{x}",
|
| 353 |
+
"suffix": lambda x, s=step.get("suffix", ""): f"{x}{s}",
|
| 354 |
+
"wrap_prompt": lambda x: f"{step.get('prefix', '')}{x}{step.get('suffix', '')}",
|
| 355 |
+
"upper": lambda x: str(x).upper(),
|
| 356 |
+
"lower": lambda x: str(x).lower(),
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
fn = transforms.get(fn_name)
|
| 360 |
+
if fn:
|
| 361 |
+
output = fn(input_val)
|
| 362 |
+
else:
|
| 363 |
+
output = input_val
|
| 364 |
+
|
| 365 |
+
return StepResult(
|
| 366 |
+
step_name=step["name"],
|
| 367 |
+
success=True,
|
| 368 |
+
output=output,
|
| 369 |
+
duration_sec=time.time() - start,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
def _step_save(self, step: Dict, context: Dict, start: float) -> StepResult:
|
| 373 |
+
"""Save an asset to disk."""
|
| 374 |
+
content = self._resolve_input(step.get("content", ""), context)
|
| 375 |
+
filename = step.get("filename", "output.txt")
|
| 376 |
+
subfolder = step.get("subfolder", "")
|
| 377 |
+
|
| 378 |
+
save_dir = self.output_dir / subfolder if subfolder else self.output_dir
|
| 379 |
+
save_dir.mkdir(parents=True, exist_ok=True)
|
| 380 |
+
save_path = save_dir / filename
|
| 381 |
+
|
| 382 |
+
if isinstance(content, str) and content.startswith("http"):
|
| 383 |
+
# Download URL
|
| 384 |
+
import urllib.request
|
| 385 |
+
urllib.request.urlretrieve(content, str(save_path))
|
| 386 |
+
elif isinstance(content, str) and os.path.isfile(content):
|
| 387 |
+
# Gradio returns local temp file paths - copy them
|
| 388 |
+
import shutil
|
| 389 |
+
shutil.copy2(content, str(save_path))
|
| 390 |
+
elif isinstance(content, (bytes, bytearray)):
|
| 391 |
+
save_path.write_bytes(content)
|
| 392 |
+
elif hasattr(content, "save"):
|
| 393 |
+
# PIL Image object
|
| 394 |
+
content.save(str(save_path))
|
| 395 |
+
else:
|
| 396 |
+
save_path.write_text(str(content))
|
| 397 |
+
|
| 398 |
+
return StepResult(
|
| 399 |
+
step_name=step["name"],
|
| 400 |
+
success=True,
|
| 401 |
+
output=str(save_path),
|
| 402 |
+
duration_sec=time.time() - start,
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
def _step_validate(self, step: Dict, context: Dict, start: float) -> StepResult:
|
| 406 |
+
"""Validate an asset meets requirements."""
|
| 407 |
+
content = self._resolve_input(step.get("input", ""), context)
|
| 408 |
+
checks = step.get("checks", [])
|
| 409 |
+
|
| 410 |
+
from gameforge.engine.validator import validate_asset
|
| 411 |
+
result = validate_asset(content, checks)
|
| 412 |
+
|
| 413 |
+
return StepResult(
|
| 414 |
+
step_name=step["name"],
|
| 415 |
+
success=result["valid"],
|
| 416 |
+
output=result,
|
| 417 |
+
error=None if result["valid"] else result.get("errors", []),
|
| 418 |
+
duration_sec=time.time() - start,
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
def _step_convert(self, step: Dict, context: Dict, start: float) -> StepResult:
|
| 422 |
+
"""Convert asset format."""
|
| 423 |
+
input_path = self._resolve_input(step.get("input", ""), context)
|
| 424 |
+
target_format = step.get("target_format", "")
|
| 425 |
+
|
| 426 |
+
from gameforge.engine.converter import convert_asset
|
| 427 |
+
output_path = convert_asset(str(input_path), target_format)
|
| 428 |
+
|
| 429 |
+
return StepResult(
|
| 430 |
+
step_name=step["name"],
|
| 431 |
+
success=output_path is not None,
|
| 432 |
+
output=output_path,
|
| 433 |
+
error=None if output_path else "Conversion failed",
|
| 434 |
+
duration_sec=time.time() - start,
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
def _step_call_space(self, step: Dict, context: Dict, start: float) -> StepResult:
|
| 438 |
+
"""Call a HF Space directly."""
|
| 439 |
+
space_id = step.get("space_id", "")
|
| 440 |
+
api_name = step.get("api_name", "/predict")
|
| 441 |
+
args = []
|
| 442 |
+
for inp in step.get("inputs", []):
|
| 443 |
+
args.append(self._resolve_input(inp, context))
|
| 444 |
+
|
| 445 |
+
try:
|
| 446 |
+
from gradio_client import Client
|
| 447 |
+
client = Client(space_id)
|
| 448 |
+
result = client.predict(*args, api_name=api_name)
|
| 449 |
+
return StepResult(
|
| 450 |
+
step_name=step["name"],
|
| 451 |
+
success=True,
|
| 452 |
+
output=result,
|
| 453 |
+
duration_sec=time.time() - start,
|
| 454 |
+
model_used=space_id,
|
| 455 |
+
execution_target="hf_space",
|
| 456 |
+
)
|
| 457 |
+
except Exception as e:
|
| 458 |
+
return StepResult(
|
| 459 |
+
step_name=step["name"],
|
| 460 |
+
success=False,
|
| 461 |
+
error=str(e),
|
| 462 |
+
duration_sec=time.time() - start,
|
| 463 |
+
model_used=space_id,
|
| 464 |
+
execution_target="hf_space",
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
+
# --- Execution Backends ---
|
| 468 |
+
|
| 469 |
+
def _exec_inference(self, model_entry: ModelEntry, params: Dict[str, Any]) -> Any:
|
| 470 |
+
"""Execute via HF Inference API."""
|
| 471 |
+
try:
|
| 472 |
+
from huggingface_hub import InferenceClient, hf_hub_download
|
| 473 |
+
import os
|
| 474 |
+
# Get token from env or cached file
|
| 475 |
+
token = os.environ.get("HF_TOKEN", "") or os.environ.get("HUGGING_FACE_HUB_TOKEN", "")
|
| 476 |
+
if not token:
|
| 477 |
+
# Try cached token
|
| 478 |
+
for token_path in [
|
| 479 |
+
os.path.expanduser("~/.cache/huggingface/token"),
|
| 480 |
+
os.path.expanduser("~/.huggingface/token"),
|
| 481 |
+
]:
|
| 482 |
+
if os.path.isfile(token_path):
|
| 483 |
+
token = open(token_path).read().strip()
|
| 484 |
+
break
|
| 485 |
+
|
| 486 |
+
# Use hf-inference provider for free tier
|
| 487 |
+
client = InferenceClient(token=token, provider="hf-inference")
|
| 488 |
+
prompt = params.get("prompt", "")
|
| 489 |
+
|
| 490 |
+
# Determine task type from model
|
| 491 |
+
model = model_entry.model
|
| 492 |
+
if "text-to-image" in model_entry.type or "flux" in model.lower() or "stable-diffusion" in model.lower():
|
| 493 |
+
return client.text_to_image(
|
| 494 |
+
prompt,
|
| 495 |
+
model=model,
|
| 496 |
+
negative_prompt=params.get("negative_prompt"),
|
| 497 |
+
num_inference_steps=params.get("num_inference_steps", 20),
|
| 498 |
+
guidance_scale=params.get("guidance_scale", 7.5),
|
| 499 |
+
)
|
| 500 |
+
elif "whisper" in model.lower() or "asr" in str(params):
|
| 501 |
+
with open(params.get("audio_path", ""), "rb") as f:
|
| 502 |
+
return client.automatic_speech_recognition(f.read(), model=model)
|
| 503 |
+
else:
|
| 504 |
+
# For chat/instruct models, use chat completion
|
| 505 |
+
if any(tag in model.lower() for tag in ["instruct", "chat", "qwen", "llama", "mistral"]):
|
| 506 |
+
messages = [{"role": "user", "content": prompt}]
|
| 507 |
+
response = client.chat.completions.create(
|
| 508 |
+
model=model,
|
| 509 |
+
messages=messages,
|
| 510 |
+
max_tokens=params.get("max_tokens", 512),
|
| 511 |
+
temperature=params.get("temperature", 0.7),
|
| 512 |
+
)
|
| 513 |
+
return response.choices[0].message.content
|
| 514 |
+
else:
|
| 515 |
+
# Default to text generation
|
| 516 |
+
response = client.text_generation(
|
| 517 |
+
prompt,
|
| 518 |
+
model=model,
|
| 519 |
+
max_new_tokens=params.get("max_tokens", 512),
|
| 520 |
+
temperature=params.get("temperature", 0.7),
|
| 521 |
+
)
|
| 522 |
+
return response
|
| 523 |
+
except Exception as e:
|
| 524 |
+
logger.error(f"Inference failed: {e}")
|
| 525 |
+
raise
|
| 526 |
+
|
| 527 |
+
def _exec_space(self, model_entry: ModelEntry, params: Dict[str, Any]) -> Any:
|
| 528 |
+
"""Execute via HF Space gradio_client."""
|
| 529 |
+
space_id = model_entry.space_id or model_entry.model
|
| 530 |
+
api_name = params.get("api_name", "/predict")
|
| 531 |
+
try:
|
| 532 |
+
from gradio_client import Client
|
| 533 |
+
client = Client(space_id)
|
| 534 |
+
prompt = params.get("prompt", "")
|
| 535 |
+
|
| 536 |
+
# Try the specified API name first, then common alternatives
|
| 537 |
+
api_names_to_try = [api_name]
|
| 538 |
+
if api_name != "/predict":
|
| 539 |
+
api_names_to_try.append("/predict")
|
| 540 |
+
api_names_to_try.extend(["/generate", "/run", "/infer", "/synthesize"])
|
| 541 |
+
|
| 542 |
+
last_error = None
|
| 543 |
+
for api in api_names_to_try:
|
| 544 |
+
try:
|
| 545 |
+
result = client.predict(prompt, api_name=api)
|
| 546 |
+
return result
|
| 547 |
+
except Exception as e:
|
| 548 |
+
last_error = e
|
| 549 |
+
# If it's an argument error, try without args
|
| 550 |
+
if "required argument" in str(e):
|
| 551 |
+
try:
|
| 552 |
+
result = client.predict(api_name=api)
|
| 553 |
+
return result
|
| 554 |
+
except Exception:
|
| 555 |
+
pass
|
| 556 |
+
continue
|
| 557 |
+
|
| 558 |
+
raise last_error or Exception(f"No working API found for {space_id}")
|
| 559 |
+
except Exception as e:
|
| 560 |
+
logger.error(f"Space call failed for {space_id}: {e}")
|
| 561 |
+
raise
|
| 562 |
+
|
| 563 |
+
def _exec_local_pipeline(self, model_entry: ModelEntry, params: Dict[str, Any]) -> Any:
|
| 564 |
+
"""Execute locally via transformers pipeline."""
|
| 565 |
+
try:
|
| 566 |
+
from transformers import pipeline as hf_pipeline
|
| 567 |
+
prompt = params.get("prompt", "")
|
| 568 |
+
pipe = hf_pipeline("text-generation", model=model_entry.model)
|
| 569 |
+
return pipe(prompt, max_new_tokens=params.get("max_tokens", 128))
|
| 570 |
+
except Exception as e:
|
| 571 |
+
logger.error(f"Local pipeline failed: {e}")
|
| 572 |
+
raise
|
| 573 |
+
|
| 574 |
+
@property
|
| 575 |
+
def history(self) -> List[PipelineRun]:
|
| 576 |
+
return self._history
|
| 577 |
+
|
| 578 |
+
def get_last_run(self) -> Optional[PipelineRun]:
|
| 579 |
+
return self._history[-1] if self._history else None
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
def get_orchestrator(output_dir: Optional[str] = None) -> Orchestrator:
|
| 583 |
+
"""Get an Orchestrator instance."""
|
| 584 |
+
return Orchestrator(output_dir)
|
gameforge/engine/router.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Router - Decides whether to run inference locally, via HF Spaces, or via free API.
|
| 3 |
+
Handles fallback logic and rate limiting awareness.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
from typing import Optional, Dict, Any, Tuple
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from enum import Enum
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
from gameforge.config.registry_loader import ModelEntry, get_registry
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ExecutionTarget(Enum):
|
| 18 |
+
"""Where to run a model."""
|
| 19 |
+
LOCAL_PIPELINE = "local_pipeline" # transformers pipeline on local machine
|
| 20 |
+
HF_INFERENCE = "hf_inference" # Free HF Inference API
|
| 21 |
+
HF_SPACE = "hf_space" # Call a HF Space via gradio_client
|
| 22 |
+
EXTERNAL = "external" # External API (Mixamo, etc.)
|
| 23 |
+
UNSUPPORTED = "unsupported" # No way to run this
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class RouteDecision:
|
| 28 |
+
"""Result of routing a model to an execution target."""
|
| 29 |
+
target: ExecutionTarget
|
| 30 |
+
model_entry: ModelEntry
|
| 31 |
+
reason: str
|
| 32 |
+
requires_gpu: bool = False
|
| 33 |
+
estimated_cost: str = "free"
|
| 34 |
+
fallback_target: Optional[ExecutionTarget] = None
|
| 35 |
+
|
| 36 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 37 |
+
return {
|
| 38 |
+
"target": self.target.value,
|
| 39 |
+
"model": self.model_entry.model,
|
| 40 |
+
"reason": self.reason,
|
| 41 |
+
"requires_gpu": self.requires_gpu,
|
| 42 |
+
"estimated_cost": self.estimated_cost,
|
| 43 |
+
"fallback": self.fallback_target.value if self.fallback_target else None,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Router:
|
| 48 |
+
"""
|
| 49 |
+
Routes model requests to the best execution target.
|
| 50 |
+
|
| 51 |
+
Decision hierarchy:
|
| 52 |
+
1. If model type is "pipeline" and model is small enough -> local
|
| 53 |
+
2. If model type is "inference" -> HF free inference API
|
| 54 |
+
3. If model type is "space" -> HF Space via gradio_client
|
| 55 |
+
4. If model type is "external" -> External API call
|
| 56 |
+
5. Fallback: try inference first, then space
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
# Known small models that can run locally on CPU
|
| 60 |
+
LOCAL_FRIENDLY = {
|
| 61 |
+
"distilbert", "distilgpt2", "distilroberta", "distilbart",
|
| 62 |
+
"blip", "whisper-small", "whisper-base", "vit-base",
|
| 63 |
+
"bge-small", "all-minilm", "musicgen-small",
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
def __init__(self):
|
| 67 |
+
self.registry = get_registry()
|
| 68 |
+
self._rate_limit_hits = 0
|
| 69 |
+
|
| 70 |
+
def route(
|
| 71 |
+
self,
|
| 72 |
+
asset_type: str,
|
| 73 |
+
variant: str = "primary",
|
| 74 |
+
force_target: Optional[str] = None,
|
| 75 |
+
prefer_free: bool = True,
|
| 76 |
+
) -> RouteDecision:
|
| 77 |
+
"""
|
| 78 |
+
Route a generation request to the best execution target.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
asset_type: e.g. "images", "video", "threed", "voice", "music"
|
| 82 |
+
variant: e.g. "primary", "fast", "cinematic"
|
| 83 |
+
force_target: Override routing ("local", "inference", "space", "external")
|
| 84 |
+
prefer_free: Prefer free tier when possible
|
| 85 |
+
"""
|
| 86 |
+
model_entry = self.registry.get_model(asset_type, variant)
|
| 87 |
+
if not model_entry:
|
| 88 |
+
return RouteDecision(
|
| 89 |
+
target=ExecutionTarget.UNSUPPORTED,
|
| 90 |
+
model_entry=ModelEntry(model="unknown", type="unknown", license="unknown",
|
| 91 |
+
status="unknown", hardware="unknown"),
|
| 92 |
+
reason=f"No model registered for {asset_type}/{variant}",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Forced override
|
| 96 |
+
if force_target:
|
| 97 |
+
return self._force_route(model_entry, force_target)
|
| 98 |
+
|
| 99 |
+
# Default routing based on model type
|
| 100 |
+
return self._auto_route(model_entry, prefer_free)
|
| 101 |
+
|
| 102 |
+
def _auto_route(self, entry: ModelEntry, prefer_free: bool) -> RouteDecision:
|
| 103 |
+
"""Auto-route based on model entry type and hardware requirements."""
|
| 104 |
+
model_lower = entry.model.lower()
|
| 105 |
+
|
| 106 |
+
# Local pipeline models
|
| 107 |
+
if entry.type == "pipeline":
|
| 108 |
+
can_run_local = any(friendly in model_lower for friendly in self.LOCAL_FRIENDLY)
|
| 109 |
+
if can_run_local or entry.hardware == "local":
|
| 110 |
+
return RouteDecision(
|
| 111 |
+
target=ExecutionTarget.LOCAL_PIPELINE,
|
| 112 |
+
model_entry=entry,
|
| 113 |
+
reason=f"Local pipeline: {entry.model} is small enough for CPU",
|
| 114 |
+
)
|
| 115 |
+
# Too big for local, try inference instead
|
| 116 |
+
return RouteDecision(
|
| 117 |
+
target=ExecutionTarget.HF_INFERENCE,
|
| 118 |
+
model_entry=entry,
|
| 119 |
+
reason=f"Model too large for local, using HF Inference API",
|
| 120 |
+
fallback_target=ExecutionTarget.HF_SPACE,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# Inference API models
|
| 124 |
+
if entry.type == "inference":
|
| 125 |
+
if entry.hardware == "free" or prefer_free:
|
| 126 |
+
return RouteDecision(
|
| 127 |
+
target=ExecutionTarget.HF_INFERENCE,
|
| 128 |
+
model_entry=entry,
|
| 129 |
+
reason=f"Free HF Inference API: {entry.model}",
|
| 130 |
+
)
|
| 131 |
+
return RouteDecision(
|
| 132 |
+
target=ExecutionTarget.HF_INFERENCE,
|
| 133 |
+
model_entry=entry,
|
| 134 |
+
reason=f"HF Inference API: {entry.model}",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# HF Space models
|
| 138 |
+
if entry.type == "space":
|
| 139 |
+
gpu_required = entry.hardware not in ("cpu-basic", "free")
|
| 140 |
+
cost = self._estimate_space_cost(entry.hardware)
|
| 141 |
+
return RouteDecision(
|
| 142 |
+
target=ExecutionTarget.HF_SPACE,
|
| 143 |
+
model_entry=entry,
|
| 144 |
+
reason=f"HF Space: {entry.space_id or entry.model}",
|
| 145 |
+
requires_gpu=gpu_required,
|
| 146 |
+
estimated_cost=cost,
|
| 147 |
+
fallback_target=ExecutionTarget.HF_INFERENCE if not gpu_required else None,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# External APIs
|
| 151 |
+
if entry.type == "external":
|
| 152 |
+
return RouteDecision(
|
| 153 |
+
target=ExecutionTarget.EXTERNAL,
|
| 154 |
+
model_entry=entry,
|
| 155 |
+
reason=f"External API: {entry.model}",
|
| 156 |
+
estimated_cost="free" if entry.hardware == "cloud" else "unknown",
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Unknown type
|
| 160 |
+
return RouteDecision(
|
| 161 |
+
target=ExecutionTarget.UNSUPPORTED,
|
| 162 |
+
model_entry=entry,
|
| 163 |
+
reason=f"Unknown model type: {entry.type}",
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
def _force_route(self, entry: ModelEntry, target: str) -> RouteDecision:
|
| 167 |
+
"""Handle forced routing."""
|
| 168 |
+
target_map = {
|
| 169 |
+
"local": ExecutionTarget.LOCAL_PIPELINE,
|
| 170 |
+
"inference": ExecutionTarget.HF_INFERENCE,
|
| 171 |
+
"space": ExecutionTarget.HF_SPACE,
|
| 172 |
+
"external": ExecutionTarget.EXTERNAL,
|
| 173 |
+
}
|
| 174 |
+
exec_target = target_map.get(target, ExecutionTarget.UNSUPPORTED)
|
| 175 |
+
return RouteDecision(
|
| 176 |
+
target=exec_target,
|
| 177 |
+
model_entry=entry,
|
| 178 |
+
reason=f"Forced to {target}",
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
def _estimate_space_cost(self, hardware: str) -> str:
|
| 182 |
+
"""Estimate cost for HF Space hardware tier."""
|
| 183 |
+
costs = {
|
| 184 |
+
"cpu-basic": "free",
|
| 185 |
+
"cpu-upgrade": "$0.03/hr",
|
| 186 |
+
"t4-small": "$0.40/hr",
|
| 187 |
+
"t4-medium": "$0.60/hr",
|
| 188 |
+
"l4x1": "$0.80/hr",
|
| 189 |
+
"a10g-small": "$1.00/hr",
|
| 190 |
+
"a10g-large": "$1.50/hr",
|
| 191 |
+
"a100-large": "$2.50/hr",
|
| 192 |
+
"zerogpu": "free (quota)",
|
| 193 |
+
}
|
| 194 |
+
return costs.get(hardware, "unknown")
|
| 195 |
+
|
| 196 |
+
def get_all_routes(
|
| 197 |
+
self,
|
| 198 |
+
asset_type: str,
|
| 199 |
+
prefer_free: bool = True,
|
| 200 |
+
) -> Dict[str, RouteDecision]:
|
| 201 |
+
"""Get routing for all variants of an asset type."""
|
| 202 |
+
asset = self.registry.get_asset(asset_type)
|
| 203 |
+
if not asset:
|
| 204 |
+
return {}
|
| 205 |
+
return {
|
| 206 |
+
variant: self.route(asset_type, variant, prefer_free=prefer_free)
|
| 207 |
+
for variant in asset.list_variants()
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def get_router() -> Router:
|
| 212 |
+
"""Get a Router instance."""
|
| 213 |
+
return Router()
|
gameforge/engine/validator.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Validator - Validates generated assets meet quality requirements.
|
| 3 |
+
Checks images, 3D meshes, audio files, etc.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional, Dict, Any, List
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def validate_asset(
|
| 16 |
+
asset_path: Any,
|
| 17 |
+
checks: List[str],
|
| 18 |
+
**kwargs,
|
| 19 |
+
) -> Dict[str, Any]:
|
| 20 |
+
"""
|
| 21 |
+
Run validation checks on an asset.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
asset_path: Path to the asset file, URL, or PIL Image
|
| 25 |
+
checks: List of check names to run
|
| 26 |
+
Returns:
|
| 27 |
+
{"valid": bool, "checks": {...}, "errors": [...]}
|
| 28 |
+
"""
|
| 29 |
+
results = {}
|
| 30 |
+
errors = []
|
| 31 |
+
|
| 32 |
+
for check_name in checks:
|
| 33 |
+
check_fn = CHECKS.get(check_name)
|
| 34 |
+
if check_fn:
|
| 35 |
+
try:
|
| 36 |
+
passed, detail = check_fn(asset_path, **kwargs)
|
| 37 |
+
results[check_name] = {"passed": passed, "detail": detail}
|
| 38 |
+
if not passed:
|
| 39 |
+
errors.append(f"{check_name}: {detail}")
|
| 40 |
+
except Exception as e:
|
| 41 |
+
results[check_name] = {"passed": False, "detail": str(e)}
|
| 42 |
+
errors.append(f"{check_name}: {e}")
|
| 43 |
+
else:
|
| 44 |
+
results[check_name] = {"passed": False, "detail": f"Unknown check: {check_name}"}
|
| 45 |
+
|
| 46 |
+
return {
|
| 47 |
+
"valid": len(errors) == 0,
|
| 48 |
+
"checks": results,
|
| 49 |
+
"errors": errors,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# === Individual Check Functions ===
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def check_file_exists(path: Any, **kw) -> tuple:
|
| 57 |
+
"""Check that the file exists."""
|
| 58 |
+
if isinstance(path, (str, Path)):
|
| 59 |
+
p = Path(path)
|
| 60 |
+
if p.exists():
|
| 61 |
+
return True, f"File exists ({p.stat().st_size} bytes)"
|
| 62 |
+
return False, f"File not found: {path}"
|
| 63 |
+
return True, "Not a file path (e.g., PIL Image or URL)"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def check_image_resolution(path: Any, min_width: int = 64, min_height: int = 64, **kw) -> tuple:
|
| 67 |
+
"""Check image meets minimum resolution."""
|
| 68 |
+
try:
|
| 69 |
+
from PIL import Image
|
| 70 |
+
if isinstance(path, str) and path.startswith("http"):
|
| 71 |
+
import urllib.request, io
|
| 72 |
+
data = urllib.request.urlopen(path).read()
|
| 73 |
+
img = Image.open(io.BytesIO(data))
|
| 74 |
+
elif isinstance(path, (str, Path)):
|
| 75 |
+
img = Image.open(path)
|
| 76 |
+
else:
|
| 77 |
+
img = path # Assume PIL Image
|
| 78 |
+
|
| 79 |
+
w, h = img.size
|
| 80 |
+
if w >= min_width and h >= min_height:
|
| 81 |
+
return True, f"Resolution {w}x{h} meets minimum {min_width}x{min_height}"
|
| 82 |
+
return False, f"Resolution {w}x{h} below minimum {min_width}x{min_height}"
|
| 83 |
+
except Exception as e:
|
| 84 |
+
return False, f"Could not check resolution: {e}"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def check_image_not_blank(path: Any, **kw) -> tuple:
|
| 88 |
+
"""Check image is not a solid color / blank."""
|
| 89 |
+
try:
|
| 90 |
+
from PIL import Image
|
| 91 |
+
import numpy as np
|
| 92 |
+
|
| 93 |
+
if isinstance(path, (str, Path)):
|
| 94 |
+
img = Image.open(path).convert("RGB")
|
| 95 |
+
else:
|
| 96 |
+
img = path.convert("RGB") if hasattr(path, "convert") else path
|
| 97 |
+
|
| 98 |
+
arr = np.array(img)
|
| 99 |
+
std = arr.std()
|
| 100 |
+
if std > 5: # Some variance exists
|
| 101 |
+
return True, f"Image has content (pixel std={std:.1f})"
|
| 102 |
+
return False, f"Image appears blank/near-uniform (pixel std={std:.1f})"
|
| 103 |
+
except ImportError:
|
| 104 |
+
return True, "numpy not available, skipping blank check"
|
| 105 |
+
except Exception as e:
|
| 106 |
+
return False, f"Could not check: {e}"
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def check_file_size(path: Any, min_bytes: int = 100, max_bytes: int = 50_000_000, **kw) -> tuple:
|
| 110 |
+
"""Check file size is within bounds."""
|
| 111 |
+
if not isinstance(path, (str, Path)):
|
| 112 |
+
return True, "Not a file path"
|
| 113 |
+
p = Path(path)
|
| 114 |
+
if not p.exists():
|
| 115 |
+
return False, "File not found"
|
| 116 |
+
size = p.stat().st_size
|
| 117 |
+
if size < min_bytes:
|
| 118 |
+
return False, f"File too small ({size} bytes < {min_bytes})"
|
| 119 |
+
if size > max_bytes:
|
| 120 |
+
return False, f"File too large ({size} bytes > {max_bytes})"
|
| 121 |
+
return True, f"File size OK ({size} bytes)"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def check_format(path: Any, expected_formats: list = None, **kw) -> tuple:
|
| 125 |
+
"""Check file has expected format/extension."""
|
| 126 |
+
if not isinstance(path, (str, Path)):
|
| 127 |
+
return True, "Not a file path"
|
| 128 |
+
ext = Path(path).suffix.lower()
|
| 129 |
+
if expected_formats:
|
| 130 |
+
if ext in expected_formats:
|
| 131 |
+
return True, f"Format {ext} is expected"
|
| 132 |
+
return False, f"Format {ext} not in expected: {expected_formats}"
|
| 133 |
+
return True, f"Format: {ext}"
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def check_3d_mesh(path: Any, **kw) -> tuple:
|
| 137 |
+
"""Check 3D mesh is valid (watertight, has UVs)."""
|
| 138 |
+
try:
|
| 139 |
+
import trimesh
|
| 140 |
+
mesh = trimesh.load(str(path))
|
| 141 |
+
info = {
|
| 142 |
+
"vertices": len(mesh.vertices) if hasattr(mesh, 'vertices') else 0,
|
| 143 |
+
"faces": len(mesh.faces) if hasattr(mesh, 'faces') else 0,
|
| 144 |
+
"watertight": mesh.is_watertight if hasattr(mesh, 'is_watertight') else None,
|
| 145 |
+
}
|
| 146 |
+
if info["vertices"] > 0:
|
| 147 |
+
return True, f"Mesh valid: {info['vertices']} verts, {info['faces']} faces"
|
| 148 |
+
return False, "Empty mesh"
|
| 149 |
+
except ImportError:
|
| 150 |
+
return True, "trimesh not installed, skipping mesh validation"
|
| 151 |
+
except Exception as e:
|
| 152 |
+
return False, f"Mesh validation failed: {e}"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def check_audio_format(path: Any, min_duration: float = 0.5, **kw) -> tuple:
|
| 156 |
+
"""Check audio file is valid and meets duration requirement."""
|
| 157 |
+
if not isinstance(path, (str, Path)):
|
| 158 |
+
return True, "Not a file path"
|
| 159 |
+
try:
|
| 160 |
+
import subprocess
|
| 161 |
+
result = subprocess.run(
|
| 162 |
+
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
| 163 |
+
"-of", "default=noprint_wrappers=1:nokey=1", str(path)],
|
| 164 |
+
capture_output=True, text=True, timeout=10,
|
| 165 |
+
)
|
| 166 |
+
if result.returncode == 0:
|
| 167 |
+
duration = float(result.stdout.strip())
|
| 168 |
+
if duration >= min_duration:
|
| 169 |
+
return True, f"Audio duration: {duration:.1f}s"
|
| 170 |
+
return False, f"Audio too short: {duration:.1f}s < {min_duration}s"
|
| 171 |
+
return False, f"ffprobe failed: {result.stderr}"
|
| 172 |
+
except FileNotFoundError:
|
| 173 |
+
return True, "ffprobe not installed, skipping audio check"
|
| 174 |
+
except Exception as e:
|
| 175 |
+
return False, f"Audio check failed: {e}"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def check_non_empty_output(path: Any, **kw) -> tuple:
|
| 179 |
+
"""Check output is not None/empty."""
|
| 180 |
+
if path is None:
|
| 181 |
+
return False, "Output is None"
|
| 182 |
+
if isinstance(path, str) and path.strip() == "":
|
| 183 |
+
return False, "Output is empty string"
|
| 184 |
+
return True, "Output is non-empty"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# === Registry of Checks ===
|
| 188 |
+
|
| 189 |
+
CHECKS = {
|
| 190 |
+
"file_exists": check_file_exists,
|
| 191 |
+
"image_resolution": check_image_resolution,
|
| 192 |
+
"image_not_blank": check_image_not_blank,
|
| 193 |
+
"file_size": check_file_size,
|
| 194 |
+
"format": check_format,
|
| 195 |
+
"3d_mesh": check_3d_mesh,
|
| 196 |
+
"audio_format": check_audio_format,
|
| 197 |
+
"non_empty": check_non_empty_output,
|
| 198 |
+
}
|
pipelines/asset_pack.yaml
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Asset Pack Pipeline
|
| 2 |
+
# ========================
|
| 3 |
+
# Generates a complete asset pack for a game scene/theme.
|
| 4 |
+
|
| 5 |
+
name: asset_pack
|
| 6 |
+
version: "1.1.0"
|
| 7 |
+
description: "Generate a complete game asset pack for a theme (all free/ZeroGPU)"
|
| 8 |
+
|
| 9 |
+
defaults:
|
| 10 |
+
theme: "fantasy forest"
|
| 11 |
+
engine: "godot"
|
| 12 |
+
|
| 13 |
+
inputs:
|
| 14 |
+
theme:
|
| 15 |
+
type: string
|
| 16 |
+
description: "Overall theme for the asset pack"
|
| 17 |
+
required: true
|
| 18 |
+
|
| 19 |
+
steps:
|
| 20 |
+
# Step 1: Generate character concept
|
| 21 |
+
- name: character_concept
|
| 22 |
+
type: generate
|
| 23 |
+
asset_type: images
|
| 24 |
+
variant: primary
|
| 25 |
+
prompt: "$theme hero character, game asset, detailed, full body"
|
| 26 |
+
params:
|
| 27 |
+
negative_prompt: "blurry, low quality"
|
| 28 |
+
num_inference_steps: 4
|
| 29 |
+
output_key: character_image
|
| 30 |
+
|
| 31 |
+
- name: save_character
|
| 32 |
+
type: save
|
| 33 |
+
content: "$character_image"
|
| 34 |
+
filename: "character.png"
|
| 35 |
+
subfolder: "asset_pack"
|
| 36 |
+
|
| 37 |
+
# Step 2: Generate environment skybox
|
| 38 |
+
- name: environment_skybox
|
| 39 |
+
type: generate
|
| 40 |
+
asset_type: images
|
| 41 |
+
variant: primary
|
| 42 |
+
prompt: "$theme environment, game skybox, 360 panorama, atmospheric"
|
| 43 |
+
params:
|
| 44 |
+
negative_prompt: "text, watermark"
|
| 45 |
+
num_inference_steps: 28
|
| 46 |
+
output_key: skybox_image
|
| 47 |
+
|
| 48 |
+
- name: save_skybox
|
| 49 |
+
type: save
|
| 50 |
+
content: "$skybox_image"
|
| 51 |
+
filename: "skybox.png"
|
| 52 |
+
subfolder: "asset_pack"
|
| 53 |
+
|
| 54 |
+
# Step 3: Generate prop
|
| 55 |
+
- name: prop_concept
|
| 56 |
+
type: generate
|
| 57 |
+
asset_type: images
|
| 58 |
+
variant: primary
|
| 59 |
+
prompt: "$theme prop, game item, detailed, clean background"
|
| 60 |
+
params:
|
| 61 |
+
negative_prompt: "blurry"
|
| 62 |
+
num_inference_steps: 4
|
| 63 |
+
output_key: prop_image
|
| 64 |
+
|
| 65 |
+
- name: save_prop
|
| 66 |
+
type: save
|
| 67 |
+
content: "$prop_image"
|
| 68 |
+
filename: "prop.png"
|
| 69 |
+
subfolder: "asset_pack"
|
| 70 |
+
|
| 71 |
+
# Step 4: Generate sound effect via TangoFlux (ZeroGPU)
|
| 72 |
+
- name: ambient_sfx
|
| 73 |
+
type: call_space
|
| 74 |
+
space_id: "declare-lab/TangoFlux"
|
| 75 |
+
api_name: "/predict"
|
| 76 |
+
inputs:
|
| 77 |
+
- "$theme ambient sound effect"
|
| 78 |
+
output_key: sfx_audio
|
| 79 |
+
optional: true
|
| 80 |
+
|
| 81 |
+
- name: save_sfx
|
| 82 |
+
type: save
|
| 83 |
+
content: "$sfx_audio"
|
| 84 |
+
filename: "ambient.wav"
|
| 85 |
+
subfolder: "asset_pack"
|
| 86 |
+
optional: true
|
| 87 |
+
|
| 88 |
+
# Step 5: Generate music via ACE-Step (ZeroGPU)
|
| 89 |
+
- name: bgm
|
| 90 |
+
type: call_space
|
| 91 |
+
space_id: "victor/ace-step-jam"
|
| 92 |
+
api_name: "/predict"
|
| 93 |
+
inputs:
|
| 94 |
+
- "$theme background music, game soundtrack"
|
| 95 |
+
output_key: music_audio
|
| 96 |
+
optional: true
|
| 97 |
+
|
| 98 |
+
- name: save_music
|
| 99 |
+
type: save
|
| 100 |
+
content: "$music_audio"
|
| 101 |
+
filename: "bgm.wav"
|
| 102 |
+
subfolder: "asset_pack"
|
| 103 |
+
optional: true
|
| 104 |
+
|
| 105 |
+
outputs:
|
| 106 |
+
- character_image
|
| 107 |
+
- skybox_image
|
| 108 |
+
- prop_image
|
| 109 |
+
- sfx_audio
|
| 110 |
+
- music_audio
|
pipelines/audio.yaml
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Audio Cue Pipeline
|
| 2 |
+
# ==================
|
| 3 |
+
# Generates sound effects and background music via ZeroGPU Spaces.
|
| 4 |
+
#
|
| 5 |
+
# Usage:
|
| 6 |
+
# orchestrator.run("audio", inputs={"prompt": "epic orchestral battle theme", "audio_type": "music"})
|
| 7 |
+
# orchestrator.run("audio", inputs={"prompt": "sword slash whoosh", "audio_type": "sfx"})
|
| 8 |
+
|
| 9 |
+
name: audio
|
| 10 |
+
version: "1.1.0"
|
| 11 |
+
description: "Generate sound effects or background music (ACE-Step / TangoFlux, ZeroGPU)"
|
| 12 |
+
|
| 13 |
+
defaults:
|
| 14 |
+
audio_type: "sfx"
|
| 15 |
+
duration: 10
|
| 16 |
+
|
| 17 |
+
inputs:
|
| 18 |
+
prompt:
|
| 19 |
+
type: string
|
| 20 |
+
description: "Sound description"
|
| 21 |
+
required: true
|
| 22 |
+
audio_type:
|
| 23 |
+
type: string
|
| 24 |
+
description: "sfx or music"
|
| 25 |
+
default: "sfx"
|
| 26 |
+
duration:
|
| 27 |
+
type: number
|
| 28 |
+
description: "Duration in seconds"
|
| 29 |
+
default: 10
|
| 30 |
+
|
| 31 |
+
steps:
|
| 32 |
+
# Step 1: Enhance prompt for audio generation
|
| 33 |
+
- name: enhance_prompt
|
| 34 |
+
type: enhance_prompt
|
| 35 |
+
prompt: "$prompt"
|
| 36 |
+
template: >
|
| 37 |
+
Create a detailed audio generation prompt for a game {audio_type}:
|
| 38 |
+
{prompt}
|
| 39 |
+
Specify instruments, tempo, mood, reverb, and production quality.
|
| 40 |
+
Format as a concise audio generation prompt.
|
| 41 |
+
output_key: enhanced_prompt
|
| 42 |
+
|
| 43 |
+
# Step 2: Generate SFX via TangoFlux (ZeroGPU)
|
| 44 |
+
- name: generate_sfx
|
| 45 |
+
type: call_space
|
| 46 |
+
space_id: "declare-lab/TangoFlux"
|
| 47 |
+
api_name: "/predict"
|
| 48 |
+
inputs:
|
| 49 |
+
- "$enhanced_prompt"
|
| 50 |
+
output_key: audio_sfx
|
| 51 |
+
optional: true
|
| 52 |
+
|
| 53 |
+
# Step 3: Generate music via ACE-Step (ZeroGPU)
|
| 54 |
+
- name: generate_music
|
| 55 |
+
type: call_space
|
| 56 |
+
space_id: "victor/ace-step-jam"
|
| 57 |
+
api_name: "/predict"
|
| 58 |
+
inputs:
|
| 59 |
+
- "$enhanced_prompt"
|
| 60 |
+
output_key: audio_music
|
| 61 |
+
optional: true
|
| 62 |
+
|
| 63 |
+
# Step 4: Select the right output based on audio_type
|
| 64 |
+
- name: select_output
|
| 65 |
+
type: transform
|
| 66 |
+
function: prefix
|
| 67 |
+
input: "$audio_sfx"
|
| 68 |
+
prefix: ""
|
| 69 |
+
output_key: audio_file
|
| 70 |
+
optional: true
|
| 71 |
+
|
| 72 |
+
# Step 5: Save audio
|
| 73 |
+
- name: save_audio
|
| 74 |
+
type: save
|
| 75 |
+
content: "$audio_sfx"
|
| 76 |
+
filename: "audio_output.wav"
|
| 77 |
+
subfolder: "audio"
|
| 78 |
+
|
| 79 |
+
# Step 6: Validate audio
|
| 80 |
+
- name: validate_audio
|
| 81 |
+
type: validate
|
| 82 |
+
input: "$audio_sfx"
|
| 83 |
+
checks:
|
| 84 |
+
- non_empty
|
| 85 |
+
- file_exists
|
| 86 |
+
- audio_format
|
| 87 |
+
optional: true
|
| 88 |
+
|
| 89 |
+
# Step 7: Convert to OGG for game engines
|
| 90 |
+
- name: convert_ogg
|
| 91 |
+
type: convert
|
| 92 |
+
input: "$audio_sfx"
|
| 93 |
+
target_format: ".ogg"
|
| 94 |
+
optional: true
|
| 95 |
+
|
| 96 |
+
- name: save_ogg
|
| 97 |
+
type: save
|
| 98 |
+
content: "$convert_ogg"
|
| 99 |
+
filename: "audio_output.ogg"
|
| 100 |
+
subfolder: "audio"
|
| 101 |
+
optional: true
|
| 102 |
+
|
| 103 |
+
outputs:
|
| 104 |
+
- enhanced_prompt
|
| 105 |
+
- audio_sfx
|
| 106 |
+
- audio_music
|
pipelines/character.yaml
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Character Pipeline
|
| 2 |
+
# ==================
|
| 3 |
+
# Generates a game character from concept art to rigged 3D model.
|
| 4 |
+
#
|
| 5 |
+
# Flow: Text prompt -> Enhanced prompt -> Concept image -> 3D mesh -> Save
|
| 6 |
+
#
|
| 7 |
+
# Usage:
|
| 8 |
+
# orchestrator.run("character", inputs={"prompt": "fantasy knight in silver armor"})
|
| 9 |
+
|
| 10 |
+
name: character
|
| 11 |
+
version: "1.1.0"
|
| 12 |
+
description: "Generate a game character: concept art -> 3D mesh"
|
| 13 |
+
|
| 14 |
+
defaults:
|
| 15 |
+
style: "game asset, detailed, professional"
|
| 16 |
+
engine: "godot"
|
| 17 |
+
image_variant: "primary"
|
| 18 |
+
threed_variant: "primary"
|
| 19 |
+
|
| 20 |
+
inputs:
|
| 21 |
+
prompt:
|
| 22 |
+
type: string
|
| 23 |
+
description: "Character description"
|
| 24 |
+
required: true
|
| 25 |
+
style:
|
| 26 |
+
type: string
|
| 27 |
+
description: "Art style modifier"
|
| 28 |
+
default: "game asset, detailed, professional"
|
| 29 |
+
engine:
|
| 30 |
+
type: string
|
| 31 |
+
description: "Target game engine"
|
| 32 |
+
default: "godot"
|
| 33 |
+
|
| 34 |
+
steps:
|
| 35 |
+
# Step 1: Enhance the prompt for better image generation
|
| 36 |
+
- name: enhance_prompt
|
| 37 |
+
type: enhance_prompt
|
| 38 |
+
prompt: "$prompt"
|
| 39 |
+
template: >
|
| 40 |
+
Create a detailed image generation prompt for a game character:
|
| 41 |
+
{prompt}
|
| 42 |
+
Style: highly detailed 3D game character, clean background,
|
| 43 |
+
T-pose or A-pose, full body view, studio lighting,
|
| 44 |
+
suitable for 3D modeling reference.
|
| 45 |
+
output_key: enhanced_prompt
|
| 46 |
+
|
| 47 |
+
# Step 2: Generate concept art using FLUX
|
| 48 |
+
- name: concept_art
|
| 49 |
+
type: generate
|
| 50 |
+
asset_type: images
|
| 51 |
+
variant: "$image_variant"
|
| 52 |
+
prompt: "$enhanced_prompt"
|
| 53 |
+
params:
|
| 54 |
+
negative_prompt: "blurry, low quality, watermark, text, signature"
|
| 55 |
+
num_inference_steps: 4
|
| 56 |
+
output_key: concept_image
|
| 57 |
+
|
| 58 |
+
# Step 3: Save concept art
|
| 59 |
+
- name: save_concept
|
| 60 |
+
type: save
|
| 61 |
+
content: "$concept_image"
|
| 62 |
+
filename: "concept_art.png"
|
| 63 |
+
subfolder: "characters"
|
| 64 |
+
|
| 65 |
+
# Step 4: Validate concept art
|
| 66 |
+
- name: validate_concept
|
| 67 |
+
type: validate
|
| 68 |
+
input: "$concept_image"
|
| 69 |
+
checks:
|
| 70 |
+
- non_empty
|
| 71 |
+
- image_not_blank
|
| 72 |
+
optional: true
|
| 73 |
+
|
| 74 |
+
# Step 5: Generate 3D mesh via TRELLIS.2 (ZeroGPU)
|
| 75 |
+
- name: generate_3d
|
| 76 |
+
type: call_space
|
| 77 |
+
space_id: "microsoft/TRELLIS.2"
|
| 78 |
+
api_name: "/generate"
|
| 79 |
+
inputs:
|
| 80 |
+
- "$concept_image"
|
| 81 |
+
output_key: mesh_3d
|
| 82 |
+
optional: true
|
| 83 |
+
|
| 84 |
+
# Step 6: Save 3D mesh
|
| 85 |
+
- name: save_mesh
|
| 86 |
+
type: save
|
| 87 |
+
content: "$mesh_3d"
|
| 88 |
+
filename: "character.glb"
|
| 89 |
+
subfolder: "characters"
|
| 90 |
+
optional: true
|
| 91 |
+
|
| 92 |
+
# Step 7: Convert for target engine
|
| 93 |
+
- name: convert_for_engine
|
| 94 |
+
type: convert
|
| 95 |
+
input: "$mesh_3d"
|
| 96 |
+
target_format: ".fbx"
|
| 97 |
+
optional: true
|
| 98 |
+
|
| 99 |
+
- name: save_engine_export
|
| 100 |
+
type: save
|
| 101 |
+
content: "$convert_for_engine"
|
| 102 |
+
filename: "character.fbx"
|
| 103 |
+
subfolder: "characters"
|
| 104 |
+
optional: true
|
| 105 |
+
|
| 106 |
+
outputs:
|
| 107 |
+
- enhanced_prompt
|
| 108 |
+
- concept_image
|
| 109 |
+
- mesh_3d
|
pipelines/cutscene.yaml
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cutscene Pipeline
|
| 2 |
+
# =================
|
| 3 |
+
# Generates short video cutscenes for games via LTX 2.3 or Wan 2.2 (ZeroGPU).
|
| 4 |
+
#
|
| 5 |
+
# Usage:
|
| 6 |
+
# orchestrator.run("cutscene", inputs={"prompt": "A dragon lands on a castle tower at sunset"})
|
| 7 |
+
|
| 8 |
+
name: cutscene
|
| 9 |
+
version: "1.1.0"
|
| 10 |
+
description: "Generate game cutscene videos (LTX 2.3 / Wan 2.2, ZeroGPU)"
|
| 11 |
+
|
| 12 |
+
defaults:
|
| 13 |
+
video_variant: "fast"
|
| 14 |
+
duration_sec: 4
|
| 15 |
+
|
| 16 |
+
inputs:
|
| 17 |
+
prompt:
|
| 18 |
+
type: string
|
| 19 |
+
description: "Scene description / storyboard"
|
| 20 |
+
required: true
|
| 21 |
+
video_variant:
|
| 22 |
+
type: string
|
| 23 |
+
description: "fast (LTX Turbo), primary (LTX 2.3), cinematic (Wan 2.2)"
|
| 24 |
+
default: "fast"
|
| 25 |
+
duration_sec:
|
| 26 |
+
type: number
|
| 27 |
+
description: "Video duration in seconds"
|
| 28 |
+
default: 4
|
| 29 |
+
ref_image:
|
| 30 |
+
type: string
|
| 31 |
+
description: "Reference image for image-to-video (optional)"
|
| 32 |
+
default: ""
|
| 33 |
+
|
| 34 |
+
steps:
|
| 35 |
+
# Step 1: Enhance the storyboard into a video prompt
|
| 36 |
+
- name: enhance_prompt
|
| 37 |
+
type: enhance_prompt
|
| 38 |
+
prompt: "$prompt"
|
| 39 |
+
template: >
|
| 40 |
+
Create a cinematic video generation prompt for a game cutscene:
|
| 41 |
+
{prompt}
|
| 42 |
+
Include camera movement (pan, zoom, tracking shot),
|
| 43 |
+
lighting description, motion details, and atmosphere.
|
| 44 |
+
Keep it under 200 words for the video model.
|
| 45 |
+
output_key: enhanced_prompt
|
| 46 |
+
|
| 47 |
+
# Step 2: Generate video via LTX-2 Turbo (ZeroGPU)
|
| 48 |
+
- name: generate_video
|
| 49 |
+
type: call_space
|
| 50 |
+
space_id: "alexnasa/ltx-2-TURBO"
|
| 51 |
+
api_name: "/generate"
|
| 52 |
+
inputs:
|
| 53 |
+
- "$enhanced_prompt"
|
| 54 |
+
output_key: video_file
|
| 55 |
+
optional: true
|
| 56 |
+
|
| 57 |
+
# Step 3: Save video
|
| 58 |
+
- name: save_video
|
| 59 |
+
type: save
|
| 60 |
+
content: "$video_file"
|
| 61 |
+
filename: "cutscene.mp4"
|
| 62 |
+
subfolder: "cutscenes"
|
| 63 |
+
optional: true
|
| 64 |
+
|
| 65 |
+
# Step 4: Validate
|
| 66 |
+
- name: validate_video
|
| 67 |
+
type: validate
|
| 68 |
+
input: "$video_file"
|
| 69 |
+
checks:
|
| 70 |
+
- non_empty
|
| 71 |
+
- file_exists
|
| 72 |
+
- file_size
|
| 73 |
+
optional: true
|
| 74 |
+
|
| 75 |
+
# Step 5: Convert to WebM for Godot
|
| 76 |
+
- name: convert_webm
|
| 77 |
+
type: convert
|
| 78 |
+
input: "$video_file"
|
| 79 |
+
target_format: ".webm"
|
| 80 |
+
optional: true
|
| 81 |
+
|
| 82 |
+
- name: save_webm
|
| 83 |
+
type: save
|
| 84 |
+
content: "$convert_webm"
|
| 85 |
+
filename: "cutscene.webm"
|
| 86 |
+
subfolder: "cutscenes"
|
| 87 |
+
optional: true
|
| 88 |
+
|
| 89 |
+
outputs:
|
| 90 |
+
- enhanced_prompt
|
| 91 |
+
- video_file
|
pipelines/environment.yaml
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment Pipeline
|
| 2 |
+
# ====================
|
| 3 |
+
# Generates game environments: skyboxes, 3D worlds (all free/ZeroGPU).
|
| 4 |
+
|
| 5 |
+
name: environment
|
| 6 |
+
version: "1.1.0"
|
| 7 |
+
description: "Generate game environment: skybox or 3D world"
|
| 8 |
+
|
| 9 |
+
defaults:
|
| 10 |
+
env_type: "skybox"
|
| 11 |
+
style: "game environment, detailed, professional"
|
| 12 |
+
image_variant: "primary"
|
| 13 |
+
|
| 14 |
+
inputs:
|
| 15 |
+
prompt:
|
| 16 |
+
type: string
|
| 17 |
+
description: "Environment description"
|
| 18 |
+
required: true
|
| 19 |
+
env_type:
|
| 20 |
+
type: string
|
| 21 |
+
description: "skybox, terrain, or world"
|
| 22 |
+
default: "skybox"
|
| 23 |
+
|
| 24 |
+
steps:
|
| 25 |
+
- name: enhance_prompt
|
| 26 |
+
type: enhance_prompt
|
| 27 |
+
prompt: "$prompt"
|
| 28 |
+
template: >
|
| 29 |
+
Create a detailed prompt for a game {env_type}:
|
| 30 |
+
{prompt}
|
| 31 |
+
Include lighting, atmosphere, color palette, mood.
|
| 32 |
+
output_key: enhanced_prompt
|
| 33 |
+
|
| 34 |
+
# Skybox generation (free Inference API)
|
| 35 |
+
- name: generate_skybox
|
| 36 |
+
type: generate
|
| 37 |
+
asset_type: images
|
| 38 |
+
variant: "$image_variant"
|
| 39 |
+
prompt: "$enhanced_prompt"
|
| 40 |
+
params:
|
| 41 |
+
prompt_suffix: ", 360 degree equirectangular panorama, seamless, game skybox"
|
| 42 |
+
negative_prompt: "text, watermark, border, frame, seams"
|
| 43 |
+
num_inference_steps: 28
|
| 44 |
+
width: 2048
|
| 45 |
+
height: 1024
|
| 46 |
+
output_key: skybox_image
|
| 47 |
+
|
| 48 |
+
- name: save_skybox
|
| 49 |
+
type: save
|
| 50 |
+
content: "$skybox_image"
|
| 51 |
+
filename: "skybox.png"
|
| 52 |
+
subfolder: "environments"
|
| 53 |
+
|
| 54 |
+
- name: validate_skybox
|
| 55 |
+
type: validate
|
| 56 |
+
input: "$skybox_image"
|
| 57 |
+
checks:
|
| 58 |
+
- non_empty
|
| 59 |
+
- image_not_blank
|
| 60 |
+
- image_resolution
|
| 61 |
+
optional: true
|
| 62 |
+
|
| 63 |
+
# Detail texture (free Inference API)
|
| 64 |
+
- name: generate_detail
|
| 65 |
+
type: generate
|
| 66 |
+
asset_type: images
|
| 67 |
+
variant: "texture"
|
| 68 |
+
prompt: "$prompt"
|
| 69 |
+
params:
|
| 70 |
+
prompt_prefix: "seamless tileable texture, top-down view, "
|
| 71 |
+
negative_prompt: "seams, borders, edges, frame, text"
|
| 72 |
+
num_inference_steps: 28
|
| 73 |
+
output_key: detail_texture
|
| 74 |
+
optional: true
|
| 75 |
+
|
| 76 |
+
- name: save_detail
|
| 77 |
+
type: save
|
| 78 |
+
content: "$detail_texture"
|
| 79 |
+
filename: "detail_texture.png"
|
| 80 |
+
subfolder: "environments"
|
| 81 |
+
optional: true
|
| 82 |
+
|
| 83 |
+
# 3D world via HY-World 2.0 (ZeroGPU)
|
| 84 |
+
- name: generate_world
|
| 85 |
+
type: call_space
|
| 86 |
+
space_id: "prithivMLmods/HY-World-2.0-Demo"
|
| 87 |
+
api_name: "/predict"
|
| 88 |
+
inputs:
|
| 89 |
+
- "$enhanced_prompt"
|
| 90 |
+
output_key: world_3d
|
| 91 |
+
optional: true
|
| 92 |
+
|
| 93 |
+
- name: save_world
|
| 94 |
+
type: save
|
| 95 |
+
content: "$world_3d"
|
| 96 |
+
filename: "world.glb"
|
| 97 |
+
subfolder: "environments"
|
| 98 |
+
optional: true
|
| 99 |
+
|
| 100 |
+
outputs:
|
| 101 |
+
- enhanced_prompt
|
| 102 |
+
- skybox_image
|
| 103 |
+
- detail_texture
|
| 104 |
+
- world_3d
|
pipelines/npc_voice.yaml
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NPC Voice Pipeline
|
| 2 |
+
# ==================
|
| 3 |
+
# Generates NPC dialogue voices using TTS with optional voice cloning.
|
| 4 |
+
#
|
| 5 |
+
# Flow: Script text -> Enhanced prompt -> TTS generation -> Convert -> Save
|
| 6 |
+
#
|
| 7 |
+
# Usage:
|
| 8 |
+
# orchestrator.run("npc_voice", inputs={"prompt": "Welcome traveler, what brings you to our village?"})
|
| 9 |
+
# With voice cloning:
|
| 10 |
+
# orchestrator.run("npc_voice", inputs={"prompt": "...", "ref_audio": "/path/to/reference.wav"})
|
| 11 |
+
|
| 12 |
+
name: npc_voice
|
| 13 |
+
version: "1.0.0"
|
| 14 |
+
description: "Generate NPC dialogue voices for games"
|
| 15 |
+
|
| 16 |
+
defaults:
|
| 17 |
+
voice_variant: "primary"
|
| 18 |
+
speed: 1.0
|
| 19 |
+
language: "EN"
|
| 20 |
+
|
| 21 |
+
inputs:
|
| 22 |
+
prompt:
|
| 23 |
+
type: string
|
| 24 |
+
description: "NPC dialogue text"
|
| 25 |
+
required: true
|
| 26 |
+
ref_audio:
|
| 27 |
+
type: string
|
| 28 |
+
description: "Reference audio for voice cloning (optional)"
|
| 29 |
+
default: ""
|
| 30 |
+
speed:
|
| 31 |
+
type: number
|
| 32 |
+
description: "Speech speed multiplier"
|
| 33 |
+
default: 1.0
|
| 34 |
+
language:
|
| 35 |
+
type: string
|
| 36 |
+
description: "Language code"
|
| 37 |
+
default: "EN"
|
| 38 |
+
|
| 39 |
+
steps:
|
| 40 |
+
# Step 1: Format the dialogue with emotion/direction
|
| 41 |
+
- name: format_dialogue
|
| 42 |
+
type: enhance_prompt
|
| 43 |
+
prompt: "$prompt"
|
| 44 |
+
template: >
|
| 45 |
+
Format this NPC dialogue for text-to-speech. Keep it natural and clear.
|
| 46 |
+
Add no markup, just clean text:
|
| 47 |
+
{prompt}
|
| 48 |
+
output_key: formatted_text
|
| 49 |
+
|
| 50 |
+
# Step 2: Generate voice using F5-TTS
|
| 51 |
+
- name: generate_voice
|
| 52 |
+
type: generate
|
| 53 |
+
asset_type: voice
|
| 54 |
+
variant: "$voice_variant"
|
| 55 |
+
prompt: "$formatted_text"
|
| 56 |
+
params:
|
| 57 |
+
speed: "$speed"
|
| 58 |
+
cross_fade_duration: 0.15
|
| 59 |
+
nfe_step: 32
|
| 60 |
+
output_key: voice_audio
|
| 61 |
+
|
| 62 |
+
# Step 3: Save voice
|
| 63 |
+
- name: save_voice
|
| 64 |
+
type: save
|
| 65 |
+
content: "$voice_audio"
|
| 66 |
+
filename: "npc_voice.wav"
|
| 67 |
+
subfolder: "voices"
|
| 68 |
+
|
| 69 |
+
# Step 4: Validate
|
| 70 |
+
- name: validate_voice
|
| 71 |
+
type: validate
|
| 72 |
+
input: "$voice_audio"
|
| 73 |
+
checks:
|
| 74 |
+
- non_empty
|
| 75 |
+
- file_exists
|
| 76 |
+
- audio_format
|
| 77 |
+
optional: true
|
| 78 |
+
|
| 79 |
+
# Step 5: Convert to OGG for game engines
|
| 80 |
+
- name: convert_ogg
|
| 81 |
+
type: convert
|
| 82 |
+
input: "$voice_audio"
|
| 83 |
+
target_format: ".ogg"
|
| 84 |
+
optional: true
|
| 85 |
+
|
| 86 |
+
- name: save_ogg
|
| 87 |
+
type: save
|
| 88 |
+
content: "$convert_ogg"
|
| 89 |
+
filename: "npc_voice.ogg"
|
| 90 |
+
subfolder: "voices"
|
| 91 |
+
optional: true
|
| 92 |
+
|
| 93 |
+
# Step 6: Also convert to WAV at 22050Hz for lightweight games
|
| 94 |
+
- name: convert_lightweight
|
| 95 |
+
type: convert
|
| 96 |
+
input: "$voice_audio"
|
| 97 |
+
target_format: ".wav"
|
| 98 |
+
optional: true
|
| 99 |
+
|
| 100 |
+
outputs:
|
| 101 |
+
- formatted_text
|
| 102 |
+
- voice_audio
|
pipelines/prop.yaml
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Prop Pipeline
|
| 2 |
+
# =============
|
| 3 |
+
# Generates individual game props: weapons, furniture, items, vehicles.
|
| 4 |
+
# Uses FLUX for concept + TRELLIS.2 for 3D mesh (both free/ZeroGPU).
|
| 5 |
+
|
| 6 |
+
name: prop
|
| 7 |
+
version: "1.1.0"
|
| 8 |
+
description: "Generate game props (weapons, items, furniture) -> concept + 3D"
|
| 9 |
+
|
| 10 |
+
defaults:
|
| 11 |
+
style: "game asset, detailed, PBR, clean background"
|
| 12 |
+
image_variant: "primary"
|
| 13 |
+
threed_variant: "primary"
|
| 14 |
+
|
| 15 |
+
inputs:
|
| 16 |
+
prompt:
|
| 17 |
+
type: string
|
| 18 |
+
description: "Prop description"
|
| 19 |
+
required: true
|
| 20 |
+
style:
|
| 21 |
+
type: string
|
| 22 |
+
description: "Art style"
|
| 23 |
+
default: "game asset, detailed, PBR, clean background"
|
| 24 |
+
|
| 25 |
+
steps:
|
| 26 |
+
- name: enhance_prompt
|
| 27 |
+
type: enhance_prompt
|
| 28 |
+
prompt: "$prompt"
|
| 29 |
+
template: >
|
| 30 |
+
Create a detailed image generation prompt for a game prop:
|
| 31 |
+
{prompt}
|
| 32 |
+
Style: game-ready 3D prop, single object, clean white/gray background,
|
| 33 |
+
studio lighting, multiple angle hint, high detail, PBR-ready.
|
| 34 |
+
output_key: enhanced_prompt
|
| 35 |
+
|
| 36 |
+
- name: concept_art
|
| 37 |
+
type: generate
|
| 38 |
+
asset_type: images
|
| 39 |
+
variant: "$image_variant"
|
| 40 |
+
prompt: "$enhanced_prompt"
|
| 41 |
+
params:
|
| 42 |
+
negative_prompt: "blurry, low quality, watermark, text, multiple objects"
|
| 43 |
+
num_inference_steps: 4
|
| 44 |
+
output_key: concept_image
|
| 45 |
+
|
| 46 |
+
- name: save_concept
|
| 47 |
+
type: save
|
| 48 |
+
content: "$concept_image"
|
| 49 |
+
filename: "prop_concept.png"
|
| 50 |
+
subfolder: "props"
|
| 51 |
+
|
| 52 |
+
- name: generate_texture
|
| 53 |
+
type: generate
|
| 54 |
+
asset_type: images
|
| 55 |
+
variant: "texture"
|
| 56 |
+
prompt: "$prompt"
|
| 57 |
+
params:
|
| 58 |
+
prompt_prefix: "PBR texture, diffuse map, seamless, "
|
| 59 |
+
negative_prompt: "3d, perspective, object, background"
|
| 60 |
+
num_inference_steps: 28
|
| 61 |
+
output_key: texture_diffuse
|
| 62 |
+
optional: true
|
| 63 |
+
|
| 64 |
+
- name: save_texture
|
| 65 |
+
type: save
|
| 66 |
+
content: "$texture_diffuse"
|
| 67 |
+
filename: "prop_texture.png"
|
| 68 |
+
subfolder: "props"
|
| 69 |
+
optional: true
|
| 70 |
+
|
| 71 |
+
# 3D mesh via TRELLIS.2 (ZeroGPU)
|
| 72 |
+
- name: generate_3d
|
| 73 |
+
type: call_space
|
| 74 |
+
space_id: "microsoft/TRELLIS.2"
|
| 75 |
+
api_name: "/generate"
|
| 76 |
+
inputs:
|
| 77 |
+
- "$concept_image"
|
| 78 |
+
output_key: mesh_3d
|
| 79 |
+
optional: true
|
| 80 |
+
|
| 81 |
+
- name: save_mesh
|
| 82 |
+
type: save
|
| 83 |
+
content: "$mesh_3d"
|
| 84 |
+
filename: "prop.glb"
|
| 85 |
+
subfolder: "props"
|
| 86 |
+
optional: true
|
| 87 |
+
|
| 88 |
+
outputs:
|
| 89 |
+
- enhanced_prompt
|
| 90 |
+
- concept_image
|
| 91 |
+
- texture_diffuse
|
| 92 |
+
- mesh_3d
|
pipelines/ui_element.yaml
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UI Elements Pipeline
|
| 2 |
+
# =====================
|
| 3 |
+
# Generates game UI elements: icons, buttons, HUD elements, menus.
|
| 4 |
+
#
|
| 5 |
+
# Flow: Text -> Enhanced prompt -> Icon/UI image -> Convert -> Save
|
| 6 |
+
#
|
| 7 |
+
# Usage:
|
| 8 |
+
# orchestrator.run("ui_element", inputs={"prompt": "health potion icon, red flask"})
|
| 9 |
+
|
| 10 |
+
name: ui_element
|
| 11 |
+
version: "1.0.0"
|
| 12 |
+
description: "Generate game UI elements (icons, buttons, HUD)"
|
| 13 |
+
|
| 14 |
+
defaults:
|
| 15 |
+
style: "game UI, pixel art OR flat design"
|
| 16 |
+
image_variant: "primary"
|
| 17 |
+
|
| 18 |
+
inputs:
|
| 19 |
+
prompt:
|
| 20 |
+
type: string
|
| 21 |
+
description: "UI element description"
|
| 22 |
+
required: true
|
| 23 |
+
style:
|
| 24 |
+
type: string
|
| 25 |
+
description: "UI style"
|
| 26 |
+
default: "game UI, flat design, clean, icon"
|
| 27 |
+
size:
|
| 28 |
+
type: string
|
| 29 |
+
description: "Output size (e.g., 64x64, 128x128, 256x256)"
|
| 30 |
+
default: "128x128"
|
| 31 |
+
|
| 32 |
+
steps:
|
| 33 |
+
# Step 1: Enhance prompt for UI generation
|
| 34 |
+
- name: enhance_prompt
|
| 35 |
+
type: enhance_prompt
|
| 36 |
+
prompt: "$prompt"
|
| 37 |
+
template: >
|
| 38 |
+
Create a prompt for a game UI element:
|
| 39 |
+
{prompt}
|
| 40 |
+
Style: clean icon, transparent background, flat design,
|
| 41 |
+
game UI element, sharp edges, readable at small sizes.
|
| 42 |
+
output_key: enhanced_prompt
|
| 43 |
+
|
| 44 |
+
# Step 2: Generate UI element
|
| 45 |
+
- name: generate_ui
|
| 46 |
+
type: generate
|
| 47 |
+
asset_type: images
|
| 48 |
+
variant: "$image_variant"
|
| 49 |
+
prompt: "$enhanced_prompt"
|
| 50 |
+
params:
|
| 51 |
+
negative_prompt: "photo, realistic, 3d render, text, watermark, blurry"
|
| 52 |
+
num_inference_steps: 4
|
| 53 |
+
output_key: ui_image
|
| 54 |
+
|
| 55 |
+
# Step 3: Save
|
| 56 |
+
- name: save_ui
|
| 57 |
+
type: save
|
| 58 |
+
content: "$ui_image"
|
| 59 |
+
filename: "ui_element.png"
|
| 60 |
+
subfolder: "ui"
|
| 61 |
+
|
| 62 |
+
# Step 4: Validate
|
| 63 |
+
- name: validate_ui
|
| 64 |
+
type: validate
|
| 65 |
+
input: "$ui_image"
|
| 66 |
+
checks:
|
| 67 |
+
- non_empty
|
| 68 |
+
- image_not_blank
|
| 69 |
+
optional: true
|
| 70 |
+
|
| 71 |
+
# Step 5: Generate additional variants (hover, pressed, disabled)
|
| 72 |
+
- name: generate_hover
|
| 73 |
+
type: generate
|
| 74 |
+
asset_type: images
|
| 75 |
+
variant: "$image_variant"
|
| 76 |
+
prompt: "$enhanced_prompt"
|
| 77 |
+
params:
|
| 78 |
+
prompt_suffix: ", bright glow, hover state, highlighted"
|
| 79 |
+
num_inference_steps: 4
|
| 80 |
+
output_key: hover_image
|
| 81 |
+
optional: true
|
| 82 |
+
|
| 83 |
+
- name: save_hover
|
| 84 |
+
type: save
|
| 85 |
+
content: "$hover_image"
|
| 86 |
+
filename: "ui_element_hover.png"
|
| 87 |
+
subfolder: "ui"
|
| 88 |
+
optional: true
|
| 89 |
+
|
| 90 |
+
outputs:
|
| 91 |
+
- enhanced_prompt
|
| 92 |
+
- ui_image
|
| 93 |
+
- hover_image
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.25.0
|
| 2 |
+
huggingface_hub>=0.28.0
|
| 3 |
+
gradio_client>=1.0.0
|
| 4 |
+
pyyaml>=6.0
|
| 5 |
+
Pillow>=10.0.0
|
| 6 |
+
numpy>=1.24.0
|
| 7 |
+
spaces>=0.30.0
|
| 8 |
+
torch>=2.0.0
|
static/css/app.css
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* GameForge - Custom Frontend Styles */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--bg-primary: #0f1117;
|
| 5 |
+
--bg-secondary: #1a1d27;
|
| 6 |
+
--bg-tertiary: #242836;
|
| 7 |
+
--bg-hover: #2d3245;
|
| 8 |
+
--text-primary: #e4e7f1;
|
| 9 |
+
--text-secondary: #9ca3bf;
|
| 10 |
+
--text-muted: #6b7394;
|
| 11 |
+
--accent: #6c5ce7;
|
| 12 |
+
--accent-hover: #7d6ff0;
|
| 13 |
+
--success: #00d68f;
|
| 14 |
+
--warning: #ffaa00;
|
| 15 |
+
--danger: #ff6b6b;
|
| 16 |
+
--border: #2d3245;
|
| 17 |
+
--radius: 8px;
|
| 18 |
+
--radius-lg: 12px;
|
| 19 |
+
--shadow: 0 4px 24px rgba(0,0,0,0.3);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 23 |
+
|
| 24 |
+
body {
|
| 25 |
+
font-family: 'Inter', -apple-system, sans-serif;
|
| 26 |
+
background: var(--bg-primary);
|
| 27 |
+
color: var(--text-primary);
|
| 28 |
+
line-height: 1.6;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#app {
|
| 32 |
+
display: flex;
|
| 33 |
+
min-height: 100vh;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Sidebar */
|
| 37 |
+
.sidebar {
|
| 38 |
+
width: 220px;
|
| 39 |
+
background: var(--bg-secondary);
|
| 40 |
+
border-right: 1px solid var(--border);
|
| 41 |
+
display: flex;
|
| 42 |
+
flex-direction: column;
|
| 43 |
+
padding: 16px 0;
|
| 44 |
+
position: fixed;
|
| 45 |
+
top: 0;
|
| 46 |
+
left: 0;
|
| 47 |
+
bottom: 0;
|
| 48 |
+
z-index: 100;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.logo {
|
| 52 |
+
display: flex;
|
| 53 |
+
align-items: center;
|
| 54 |
+
gap: 10px;
|
| 55 |
+
padding: 0 20px 20px;
|
| 56 |
+
border-bottom: 1px solid var(--border);
|
| 57 |
+
margin-bottom: 16px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.logo-icon { font-size: 24px; }
|
| 61 |
+
.logo-text { font-size: 18px; font-weight: 700; color: var(--accent); }
|
| 62 |
+
|
| 63 |
+
.nav { list-style: none; flex: 1; }
|
| 64 |
+
|
| 65 |
+
.nav-item {
|
| 66 |
+
padding: 10px 20px;
|
| 67 |
+
cursor: pointer;
|
| 68 |
+
display: flex;
|
| 69 |
+
align-items: center;
|
| 70 |
+
gap: 10px;
|
| 71 |
+
color: var(--text-secondary);
|
| 72 |
+
transition: all 0.15s;
|
| 73 |
+
font-size: 14px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.nav-item:hover { background: var(--bg-hover); color: var(--text-primary); }
|
| 77 |
+
.nav-item.active { background: var(--bg-tertiary); color: var(--accent); border-left: 3px solid var(--accent); }
|
| 78 |
+
|
| 79 |
+
.nav-icon { font-size: 16px; width: 20px; text-align: center; }
|
| 80 |
+
|
| 81 |
+
.sidebar-footer {
|
| 82 |
+
padding: 16px 20px;
|
| 83 |
+
border-top: 1px solid var(--border);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.gpu-status {
|
| 87 |
+
display: flex;
|
| 88 |
+
align-items: center;
|
| 89 |
+
gap: 8px;
|
| 90 |
+
font-size: 12px;
|
| 91 |
+
color: var(--text-muted);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.status-dot {
|
| 95 |
+
width: 8px;
|
| 96 |
+
height: 8px;
|
| 97 |
+
border-radius: 50%;
|
| 98 |
+
background: var(--success);
|
| 99 |
+
animation: pulse 2s infinite;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
@keyframes pulse {
|
| 103 |
+
0%, 100% { opacity: 1; }
|
| 104 |
+
50% { opacity: 0.5; }
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* Main content */
|
| 108 |
+
.main {
|
| 109 |
+
flex: 1;
|
| 110 |
+
margin-left: 220px;
|
| 111 |
+
padding: 24px 32px;
|
| 112 |
+
max-width: 1200px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.tab-content { display: none; }
|
| 116 |
+
.tab-content.active { display: block; }
|
| 117 |
+
|
| 118 |
+
h1 { font-size: 24px; font-weight: 700; margin-bottom: 24px; }
|
| 119 |
+
h2 { font-size: 18px; font-weight: 600; margin: 24px 0 16px; color: var(--text-secondary); }
|
| 120 |
+
|
| 121 |
+
/* Stats grid */
|
| 122 |
+
.stats-grid {
|
| 123 |
+
display: grid;
|
| 124 |
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
| 125 |
+
gap: 16px;
|
| 126 |
+
margin-bottom: 32px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.stat-card {
|
| 130 |
+
background: var(--bg-secondary);
|
| 131 |
+
border: 1px solid var(--border);
|
| 132 |
+
border-radius: var(--radius-lg);
|
| 133 |
+
padding: 20px;
|
| 134 |
+
text-align: center;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.stat-value {
|
| 138 |
+
font-size: 32px;
|
| 139 |
+
font-weight: 700;
|
| 140 |
+
color: var(--accent);
|
| 141 |
+
font-family: 'JetBrains Mono', monospace;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.stat-label {
|
| 145 |
+
font-size: 13px;
|
| 146 |
+
color: var(--text-muted);
|
| 147 |
+
margin-top: 4px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/* Quick generate */
|
| 151 |
+
.quick-gen-grid {
|
| 152 |
+
display: grid;
|
| 153 |
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 154 |
+
gap: 12px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.quick-gen-card {
|
| 158 |
+
background: var(--bg-secondary);
|
| 159 |
+
border: 1px solid var(--border);
|
| 160 |
+
border-radius: var(--radius-lg);
|
| 161 |
+
padding: 24px 16px;
|
| 162 |
+
text-align: center;
|
| 163 |
+
cursor: pointer;
|
| 164 |
+
transition: all 0.15s;
|
| 165 |
+
display: flex;
|
| 166 |
+
flex-direction: column;
|
| 167 |
+
align-items: center;
|
| 168 |
+
gap: 8px;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.quick-gen-card:hover {
|
| 172 |
+
border-color: var(--accent);
|
| 173 |
+
background: var(--bg-tertiary);
|
| 174 |
+
transform: translateY(-2px);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.qg-icon { font-size: 32px; }
|
| 178 |
+
.qg-label { font-size: 14px; font-weight: 500; }
|
| 179 |
+
|
| 180 |
+
/* Generate panel */
|
| 181 |
+
.generate-panel {
|
| 182 |
+
display: grid;
|
| 183 |
+
grid-template-columns: 1fr 1fr;
|
| 184 |
+
gap: 24px;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.gen-form { display: flex; flex-direction: column; gap: 16px; }
|
| 188 |
+
|
| 189 |
+
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
| 190 |
+
.form-group label { font-size: 13px; font-weight: 500; color: var(--text-secondary); }
|
| 191 |
+
|
| 192 |
+
input[type="text"], textarea, select {
|
| 193 |
+
background: var(--bg-tertiary);
|
| 194 |
+
border: 1px solid var(--border);
|
| 195 |
+
border-radius: var(--radius);
|
| 196 |
+
padding: 10px 14px;
|
| 197 |
+
color: var(--text-primary);
|
| 198 |
+
font-family: inherit;
|
| 199 |
+
font-size: 14px;
|
| 200 |
+
outline: none;
|
| 201 |
+
transition: border-color 0.15s;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
input:focus, textarea:focus, select:focus { border-color: var(--accent); }
|
| 205 |
+
|
| 206 |
+
.btn {
|
| 207 |
+
border: none;
|
| 208 |
+
border-radius: var(--radius);
|
| 209 |
+
padding: 10px 20px;
|
| 210 |
+
font-family: inherit;
|
| 211 |
+
font-size: 14px;
|
| 212 |
+
font-weight: 500;
|
| 213 |
+
cursor: pointer;
|
| 214 |
+
transition: all 0.15s;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.btn-primary { background: var(--accent); color: white; }
|
| 218 |
+
.btn-primary:hover { background: var(--accent-hover); }
|
| 219 |
+
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 220 |
+
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); }
|
| 221 |
+
.btn-secondary:hover { background: var(--bg-hover); }
|
| 222 |
+
.btn-lg { padding: 14px 28px; font-size: 16px; }
|
| 223 |
+
|
| 224 |
+
/* Preview box */
|
| 225 |
+
.preview-box {
|
| 226 |
+
background: var(--bg-secondary);
|
| 227 |
+
border: 1px solid var(--border);
|
| 228 |
+
border-radius: var(--radius-lg);
|
| 229 |
+
min-height: 400px;
|
| 230 |
+
display: flex;
|
| 231 |
+
align-items: center;
|
| 232 |
+
justify-content: center;
|
| 233 |
+
overflow: hidden;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.preview-placeholder {
|
| 237 |
+
text-align: center;
|
| 238 |
+
color: var(--text-muted);
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.preview-placeholder span { font-size: 48px; display: block; margin-bottom: 12px; }
|
| 242 |
+
|
| 243 |
+
.preview-box img, .preview-box video, .preview-box audio {
|
| 244 |
+
max-width: 100%;
|
| 245 |
+
max-height: 100%;
|
| 246 |
+
border-radius: var(--radius);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/* Pipeline grid */
|
| 250 |
+
.pipeline-grid {
|
| 251 |
+
display: grid;
|
| 252 |
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
| 253 |
+
gap: 16px;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.pipeline-card {
|
| 257 |
+
background: var(--bg-secondary);
|
| 258 |
+
border: 1px solid var(--border);
|
| 259 |
+
border-radius: var(--radius-lg);
|
| 260 |
+
padding: 20px;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.pipeline-card h3 { font-size: 16px; margin-bottom: 8px; }
|
| 264 |
+
.pipeline-card p { font-size: 13px; color: var(--text-muted); margin-bottom: 12px; }
|
| 265 |
+
|
| 266 |
+
.pipeline-meta {
|
| 267 |
+
display: flex;
|
| 268 |
+
gap: 12px;
|
| 269 |
+
font-size: 12px;
|
| 270 |
+
color: var(--text-secondary);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
/* Assets grid */
|
| 274 |
+
.assets-toolbar {
|
| 275 |
+
display: flex;
|
| 276 |
+
gap: 12px;
|
| 277 |
+
margin-bottom: 20px;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.assets-grid {
|
| 281 |
+
display: grid;
|
| 282 |
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
| 283 |
+
gap: 12px;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.asset-card {
|
| 287 |
+
background: var(--bg-secondary);
|
| 288 |
+
border: 1px solid var(--border);
|
| 289 |
+
border-radius: var(--radius);
|
| 290 |
+
padding: 12px;
|
| 291 |
+
font-size: 13px;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.asset-card .name { font-weight: 500; word-break: break-all; }
|
| 295 |
+
.asset-card .meta { color: var(--text-muted); font-size: 12px; margin-top: 4px; }
|
| 296 |
+
|
| 297 |
+
/* Models table */
|
| 298 |
+
.models-filters {
|
| 299 |
+
display: flex;
|
| 300 |
+
gap: 16px;
|
| 301 |
+
margin-bottom: 16px;
|
| 302 |
+
align-items: center;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.models-table {
|
| 306 |
+
width: 100%;
|
| 307 |
+
border-collapse: collapse;
|
| 308 |
+
font-size: 13px;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
.models-table th {
|
| 312 |
+
text-align: left;
|
| 313 |
+
padding: 10px 12px;
|
| 314 |
+
background: var(--bg-secondary);
|
| 315 |
+
color: var(--text-secondary);
|
| 316 |
+
font-weight: 500;
|
| 317 |
+
border-bottom: 1px solid var(--border);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.models-table td {
|
| 321 |
+
padding: 8px 12px;
|
| 322 |
+
border-bottom: 1px solid var(--border);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.models-table tr:hover { background: var(--bg-hover); }
|
| 326 |
+
|
| 327 |
+
.badge {
|
| 328 |
+
display: inline-block;
|
| 329 |
+
padding: 2px 8px;
|
| 330 |
+
border-radius: 4px;
|
| 331 |
+
font-size: 11px;
|
| 332 |
+
font-weight: 500;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.badge-free { background: rgba(0,214,143,0.15); color: var(--success); }
|
| 336 |
+
.badge-paid { background: rgba(255,170,0,0.15); color: var(--warning); }
|
| 337 |
+
.badge-safe { background: rgba(108,92,231,0.15); color: var(--accent); }
|
| 338 |
+
.badge-check { background: rgba(255,107,107,0.15); color: var(--danger); }
|
| 339 |
+
|
| 340 |
+
/* Batch */
|
| 341 |
+
.batch-form {
|
| 342 |
+
background: var(--bg-secondary);
|
| 343 |
+
border: 1px solid var(--border);
|
| 344 |
+
border-radius: var(--radius-lg);
|
| 345 |
+
padding: 24px;
|
| 346 |
+
margin-bottom: 24px;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.batch-results {
|
| 350 |
+
display: grid;
|
| 351 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 352 |
+
gap: 12px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/* Toast */
|
| 356 |
+
#toast-container {
|
| 357 |
+
position: fixed;
|
| 358 |
+
bottom: 24px;
|
| 359 |
+
right: 24px;
|
| 360 |
+
z-index: 1000;
|
| 361 |
+
display: flex;
|
| 362 |
+
flex-direction: column;
|
| 363 |
+
gap: 8px;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.toast {
|
| 367 |
+
background: var(--bg-tertiary);
|
| 368 |
+
border: 1px solid var(--border);
|
| 369 |
+
border-radius: var(--radius);
|
| 370 |
+
padding: 12px 20px;
|
| 371 |
+
font-size: 14px;
|
| 372 |
+
animation: slideIn 0.2s ease-out;
|
| 373 |
+
max-width: 400px;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.toast-success { border-left: 3px solid var(--success); }
|
| 377 |
+
.toast-error { border-left: 3px solid var(--danger); }
|
| 378 |
+
.toast-info { border-left: 3px solid var(--accent); }
|
| 379 |
+
|
| 380 |
+
@keyframes slideIn {
|
| 381 |
+
from { transform: translateX(100%); opacity: 0; }
|
| 382 |
+
to { transform: translateX(0); opacity: 1; }
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/* Responsive */
|
| 386 |
+
@media (max-width: 768px) {
|
| 387 |
+
.sidebar { width: 60px; }
|
| 388 |
+
.logo-text, .nav-item span:not(.nav-icon) { display: none; }
|
| 389 |
+
.main { margin-left: 60px; padding: 16px; }
|
| 390 |
+
.generate-panel { grid-template-columns: 1fr; }
|
| 391 |
+
}
|
static/index.html
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>GameForge - AI Game Asset Pipeline</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/app.css">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="app">
|
| 13 |
+
<!-- Sidebar -->
|
| 14 |
+
<nav class="sidebar">
|
| 15 |
+
<div class="logo">
|
| 16 |
+
<span class="logo-icon">⚔️</span>
|
| 17 |
+
<span class="logo-text">GameForge</span>
|
| 18 |
+
</div>
|
| 19 |
+
<ul class="nav">
|
| 20 |
+
<li class="nav-item active" data-tab="dashboard">
|
| 21 |
+
<span class="nav-icon">📊</span> Dashboard
|
| 22 |
+
</li>
|
| 23 |
+
<li class="nav-item" data-tab="generate">
|
| 24 |
+
<span class="nav-icon">✨</span> Generate
|
| 25 |
+
</li>
|
| 26 |
+
<li class="nav-item" data-tab="pipelines">
|
| 27 |
+
<span class="nav-icon">🔗</span> Pipelines
|
| 28 |
+
</li>
|
| 29 |
+
<li class="nav-item" data-tab="assets">
|
| 30 |
+
<span class="nav-icon">📁</span> Assets
|
| 31 |
+
</li>
|
| 32 |
+
<li class="nav-item" data-tab="models">
|
| 33 |
+
<span class="nav-icon">🧠</span> Models
|
| 34 |
+
</li>
|
| 35 |
+
<li class="nav-item" data-tab="batch">
|
| 36 |
+
<span class="nav-icon">📦</span> Batch
|
| 37 |
+
</li>
|
| 38 |
+
</ul>
|
| 39 |
+
<div class="sidebar-footer">
|
| 40 |
+
<div class="gpu-status">
|
| 41 |
+
<span class="status-dot"></span>
|
| 42 |
+
<span>ZeroGPU Ready</span>
|
| 43 |
+
</div>
|
| 44 |
+
</div>
|
| 45 |
+
</nav>
|
| 46 |
+
|
| 47 |
+
<!-- Main Content -->
|
| 48 |
+
<main class="main">
|
| 49 |
+
<!-- Dashboard -->
|
| 50 |
+
<section class="tab-content active" id="tab-dashboard">
|
| 51 |
+
<h1>Dashboard</h1>
|
| 52 |
+
<div class="stats-grid">
|
| 53 |
+
<div class="stat-card">
|
| 54 |
+
<div class="stat-value" id="stat-models">--</div>
|
| 55 |
+
<div class="stat-label">Models Available</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="stat-card">
|
| 58 |
+
<div class="stat-value" id="stat-free">--</div>
|
| 59 |
+
<div class="stat-label">Free (ZeroGPU)</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="stat-card">
|
| 62 |
+
<div class="stat-value" id="stat-pipelines">--</div>
|
| 63 |
+
<div class="stat-label">Pipelines</div>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="stat-card">
|
| 66 |
+
<div class="stat-value" id="stat-assets">--</div>
|
| 67 |
+
<div class="stat-label">Generated Assets</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
<h2>Quick Generate</h2>
|
| 71 |
+
<div class="quick-gen-grid">
|
| 72 |
+
<button class="quick-gen-card" data-pipeline="character" data-prompt="fantasy knight in silver armor">
|
| 73 |
+
<span class="qg-icon">⚔️</span>
|
| 74 |
+
<span class="qg-label">Character</span>
|
| 75 |
+
</button>
|
| 76 |
+
<button class="quick-gen-card" data-pipeline="prop" data-prompt="enchanted magic sword with glowing runes">
|
| 77 |
+
<span class="qg-icon">🗡️</span>
|
| 78 |
+
<span class="qg-label">Prop</span>
|
| 79 |
+
</button>
|
| 80 |
+
<button class="quick-gen-card" data-pipeline="environment" data-prompt="dark fantasy forest clearing at twilight">
|
| 81 |
+
<span class="qg-icon">🌲</span>
|
| 82 |
+
<span class="qg-label">Environment</span>
|
| 83 |
+
</button>
|
| 84 |
+
<button class="quick-gen-card" data-pipeline="npc_voice" data-prompt="Welcome, traveler. What brings you to our village?">
|
| 85 |
+
<span class="qg-icon">🗣️</span>
|
| 86 |
+
<span class="qg-label">NPC Voice</span>
|
| 87 |
+
</button>
|
| 88 |
+
<button class="quick-gen-card" data-pipeline="audio" data-prompt="epic orchestral battle theme">
|
| 89 |
+
<span class="qg-icon">🎵</span>
|
| 90 |
+
<span class="qg-label">Music</span>
|
| 91 |
+
</button>
|
| 92 |
+
<button class="quick-gen-card" data-pipeline="audio" data-prompt="sword slash whoosh with metallic ring">
|
| 93 |
+
<span class="qg-icon">💥</span>
|
| 94 |
+
<span class="qg-label">SFX</span>
|
| 95 |
+
</button>
|
| 96 |
+
</div>
|
| 97 |
+
</section>
|
| 98 |
+
|
| 99 |
+
<!-- Generate -->
|
| 100 |
+
<section class="tab-content" id="tab-generate">
|
| 101 |
+
<h1>Generate Asset</h1>
|
| 102 |
+
<div class="generate-panel">
|
| 103 |
+
<div class="gen-form">
|
| 104 |
+
<div class="form-group">
|
| 105 |
+
<label>Asset Type</label>
|
| 106 |
+
<select id="gen-type">
|
| 107 |
+
<option value="image">Image (FLUX)</option>
|
| 108 |
+
<option value="3d">3D Model (TRELLIS.2)</option>
|
| 109 |
+
<option value="voice">NPC Voice (MeloTTS)</option>
|
| 110 |
+
<option value="music">Music (ACE-Step)</option>
|
| 111 |
+
<option value="video">Video (LTX 2.3)</option>
|
| 112 |
+
<option value="sfx">Sound Effect (TangoFlux)</option>
|
| 113 |
+
</select>
|
| 114 |
+
</div>
|
| 115 |
+
<div class="form-group">
|
| 116 |
+
<label>Prompt</label>
|
| 117 |
+
<textarea id="gen-prompt" placeholder="Describe your asset..." rows="4"></textarea>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="form-group" id="neg-prompt-group">
|
| 120 |
+
<label>Negative Prompt (optional)</label>
|
| 121 |
+
<input type="text" id="gen-negative" placeholder="blurry, low quality, watermark">
|
| 122 |
+
</div>
|
| 123 |
+
<div class="form-group" id="ref-image-group" style="display:none">
|
| 124 |
+
<label>Reference Image (for 3D)</label>
|
| 125 |
+
<input type="file" id="gen-ref-image" accept="image/*">
|
| 126 |
+
</div>
|
| 127 |
+
<button class="btn btn-primary btn-lg" id="gen-btn">
|
| 128 |
+
<span class="btn-text">Generate</span>
|
| 129 |
+
<span class="btn-loading" style="display:none">Generating...</span>
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
<div class="gen-preview">
|
| 133 |
+
<div class="preview-box" id="gen-preview-box">
|
| 134 |
+
<div class="preview-placeholder">
|
| 135 |
+
<span>✨</span>
|
| 136 |
+
<p>Your generated asset will appear here</p>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="gen-result-info" id="gen-result-info" style="display:none">
|
| 140 |
+
<div id="gen-result-meta"></div>
|
| 141 |
+
<button class="btn btn-secondary" id="gen-download">Download</button>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</section>
|
| 146 |
+
|
| 147 |
+
<!-- Pipelines -->
|
| 148 |
+
<section class="tab-content" id="tab-pipelines">
|
| 149 |
+
<h1>Pipelines</h1>
|
| 150 |
+
<div class="pipeline-grid" id="pipeline-grid">
|
| 151 |
+
<!-- Loaded dynamically -->
|
| 152 |
+
</div>
|
| 153 |
+
</section>
|
| 154 |
+
|
| 155 |
+
<!-- Assets -->
|
| 156 |
+
<section class="tab-content" id="tab-assets">
|
| 157 |
+
<h1>Generated Assets</h1>
|
| 158 |
+
<div class="assets-toolbar">
|
| 159 |
+
<select id="asset-filter">
|
| 160 |
+
<option value="">All Types</option>
|
| 161 |
+
</select>
|
| 162 |
+
<button class="btn btn-secondary" id="asset-refresh">Refresh</button>
|
| 163 |
+
</div>
|
| 164 |
+
<div class="assets-grid" id="assets-grid">
|
| 165 |
+
<!-- Loaded dynamically -->
|
| 166 |
+
</div>
|
| 167 |
+
</section>
|
| 168 |
+
|
| 169 |
+
<!-- Models -->
|
| 170 |
+
<section class="tab-content" id="tab-models">
|
| 171 |
+
<h1>Model Registry</h1>
|
| 172 |
+
<div class="models-filters">
|
| 173 |
+
<select id="model-filter-type">
|
| 174 |
+
<option value="">All Asset Types</option>
|
| 175 |
+
</select>
|
| 176 |
+
<label><input type="checkbox" id="model-filter-free"> Free Only</label>
|
| 177 |
+
<label><input type="checkbox" id="model-filter-safe"> Commercial Safe</label>
|
| 178 |
+
</div>
|
| 179 |
+
<table class="models-table" id="models-table">
|
| 180 |
+
<thead>
|
| 181 |
+
<tr>
|
| 182 |
+
<th>Asset Type</th>
|
| 183 |
+
<th>Variant</th>
|
| 184 |
+
<th>Model</th>
|
| 185 |
+
<th>Type</th>
|
| 186 |
+
<th>License</th>
|
| 187 |
+
<th>Hardware</th>
|
| 188 |
+
<th>Free</th>
|
| 189 |
+
<th>Safe</th>
|
| 190 |
+
</tr>
|
| 191 |
+
</thead>
|
| 192 |
+
<tbody id="models-tbody"></tbody>
|
| 193 |
+
</table>
|
| 194 |
+
</section>
|
| 195 |
+
|
| 196 |
+
<!-- Batch -->
|
| 197 |
+
<section class="tab-content" id="tab-batch">
|
| 198 |
+
<h1>Batch Generator</h1>
|
| 199 |
+
<div class="batch-form">
|
| 200 |
+
<div class="form-group">
|
| 201 |
+
<label>Pipeline</label>
|
| 202 |
+
<select id="batch-pipeline"></select>
|
| 203 |
+
</div>
|
| 204 |
+
<div class="form-group">
|
| 205 |
+
<label>Base Prompt</label>
|
| 206 |
+
<textarea id="batch-prompt" placeholder="enchanted sword with glowing runes" rows="2"></textarea>
|
| 207 |
+
</div>
|
| 208 |
+
<div class="form-group">
|
| 209 |
+
<label>Variants: <span id="batch-count-val">3</span></label>
|
| 210 |
+
<input type="range" id="batch-count" min="1" max="10" value="3">
|
| 211 |
+
</div>
|
| 212 |
+
<button class="btn btn-primary" id="batch-btn">Generate Variants</button>
|
| 213 |
+
</div>
|
| 214 |
+
<div class="batch-results" id="batch-results"></div>
|
| 215 |
+
</section>
|
| 216 |
+
</main>
|
| 217 |
+
|
| 218 |
+
<!-- Toast notifications -->
|
| 219 |
+
<div id="toast-container"></div>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<script type="module" src="/static/js/app.js"></script>
|
| 223 |
+
</body>
|
| 224 |
+
</html>
|
static/js/app.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* GameForge Frontend
|
| 3 |
+
* Custom HTML/CSS/JS app powered by gradio.Server + ZeroGPU.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
|
| 7 |
+
|
| 8 |
+
// ============================================================================
|
| 9 |
+
// State
|
| 10 |
+
// ============================================================================
|
| 11 |
+
|
| 12 |
+
let client = null;
|
| 13 |
+
let registryData = [];
|
| 14 |
+
let pipelines = [];
|
| 15 |
+
|
| 16 |
+
// ============================================================================
|
| 17 |
+
// Gradio Client Connection
|
| 18 |
+
// ============================================================================
|
| 19 |
+
|
| 20 |
+
async function connect() {
|
| 21 |
+
try {
|
| 22 |
+
client = await Client.connect(window.location.origin);
|
| 23 |
+
toast("Connected to GameForge", "success");
|
| 24 |
+
await loadDashboard();
|
| 25 |
+
} catch (e) {
|
| 26 |
+
toast("Failed to connect: " + e.message, "error");
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// ============================================================================
|
| 31 |
+
// API Helpers
|
| 32 |
+
// ============================================================================
|
| 33 |
+
|
| 34 |
+
async function api(endpoint, params = {}) {
|
| 35 |
+
if (!client) await connect();
|
| 36 |
+
try {
|
| 37 |
+
const result = await client.predict(endpoint, params);
|
| 38 |
+
return result.data;
|
| 39 |
+
} catch (e) {
|
| 40 |
+
toast(`API error (${endpoint}): ${e.message}`, "error");
|
| 41 |
+
throw e;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// ============================================================================
|
| 46 |
+
// Navigation
|
| 47 |
+
// ============================================================================
|
| 48 |
+
|
| 49 |
+
document.querySelectorAll(".nav-item").forEach(item => {
|
| 50 |
+
item.addEventListener("click", () => {
|
| 51 |
+
document.querySelectorAll(".nav-item").forEach(i => i.classList.remove("active"));
|
| 52 |
+
document.querySelectorAll(".tab-content").forEach(t => t.classList.remove("active"));
|
| 53 |
+
item.classList.add("active");
|
| 54 |
+
const tab = document.getElementById("tab-" + item.dataset.tab);
|
| 55 |
+
if (tab) tab.classList.add("active");
|
| 56 |
+
|
| 57 |
+
// Load data for tab
|
| 58 |
+
if (item.dataset.tab === "models") loadModels();
|
| 59 |
+
if (item.dataset.tab === "pipelines") loadPipelines();
|
| 60 |
+
if (item.dataset.tab === "assets") loadAssets();
|
| 61 |
+
if (item.dataset.tab === "batch") loadBatchPipelines();
|
| 62 |
+
});
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
// ============================================================================
|
| 66 |
+
// Dashboard
|
| 67 |
+
// ============================================================================
|
| 68 |
+
|
| 69 |
+
async function loadDashboard() {
|
| 70 |
+
try {
|
| 71 |
+
const [reg, pipes, assets] = await Promise.all([
|
| 72 |
+
api("/registry_info"),
|
| 73 |
+
api("/list_pipelines"),
|
| 74 |
+
api("/list_assets"),
|
| 75 |
+
]);
|
| 76 |
+
|
| 77 |
+
registryData = reg.models || [];
|
| 78 |
+
pipelines = pipes || [];
|
| 79 |
+
|
| 80 |
+
document.getElementById("stat-models").textContent = registryData.length;
|
| 81 |
+
document.getElementById("stat-free").textContent = registryData.filter(m => m.free).length;
|
| 82 |
+
document.getElementById("stat-pipelines").textContent = pipelines.length;
|
| 83 |
+
document.getElementById("stat-assets").textContent = assets.length;
|
| 84 |
+
|
| 85 |
+
// Quick generate buttons
|
| 86 |
+
document.querySelectorAll(".quick-gen-card").forEach(card => {
|
| 87 |
+
card.addEventListener("click", () => {
|
| 88 |
+
const typeMap = {
|
| 89 |
+
character: "image", prop: "image", environment: "image",
|
| 90 |
+
npc_voice: "voice", audio: "music",
|
| 91 |
+
};
|
| 92 |
+
const type = typeMap[card.dataset.pipeline] || "image";
|
| 93 |
+
document.getElementById("gen-type").value = type;
|
| 94 |
+
document.getElementById("gen-prompt").value = card.dataset.prompt;
|
| 95 |
+
switchTab("generate");
|
| 96 |
+
document.getElementById("gen-btn").click();
|
| 97 |
+
});
|
| 98 |
+
});
|
| 99 |
+
} catch (e) {
|
| 100 |
+
console.error("Dashboard load failed:", e);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function switchTab(name) {
|
| 105 |
+
document.querySelector(`.nav-item[data-tab="${name}"]`)?.click();
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// ============================================================================
|
| 109 |
+
// Generate
|
| 110 |
+
// ============================================================================
|
| 111 |
+
|
| 112 |
+
const genType = document.getElementById("gen-type");
|
| 113 |
+
const genBtn = document.getElementById("gen-btn");
|
| 114 |
+
const genPrompt = document.getElementById("gen-prompt");
|
| 115 |
+
const genPreview = document.getElementById("gen-preview-box");
|
| 116 |
+
const genInfo = document.getElementById("gen-result-info");
|
| 117 |
+
|
| 118 |
+
genType.addEventListener("change", () => {
|
| 119 |
+
const is3d = genType.value === "3d";
|
| 120 |
+
document.getElementById("ref-image-group").style.display = is3d ? "block" : "none";
|
| 121 |
+
document.getElementById("neg-prompt-group").style.display = (genType.value === "image") ? "block" : "none";
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
genBtn.addEventListener("click", async () => {
|
| 125 |
+
const prompt = genPrompt.value.trim();
|
| 126 |
+
if (!prompt) { toast("Enter a prompt", "error"); return; }
|
| 127 |
+
|
| 128 |
+
const type = genType.value;
|
| 129 |
+
const btnText = genBtn.querySelector(".btn-text");
|
| 130 |
+
const btnLoad = genBtn.querySelector(".btn-loading");
|
| 131 |
+
|
| 132 |
+
btnText.style.display = "none";
|
| 133 |
+
btnLoad.style.display = "inline";
|
| 134 |
+
genBtn.disabled = true;
|
| 135 |
+
|
| 136 |
+
genPreview.innerHTML = '<div class="preview-placeholder"><span>⏳</span><p>Generating...</p></div>';
|
| 137 |
+
|
| 138 |
+
try {
|
| 139 |
+
const endpointMap = {
|
| 140 |
+
image: "/generate_image",
|
| 141 |
+
voice: "/generate_voice",
|
| 142 |
+
music: "/generate_music",
|
| 143 |
+
video: "/generate_video",
|
| 144 |
+
sfx: "/generate_sfx",
|
| 145 |
+
"3d": "/generate_3d",
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
const endpoint = endpointMap[type];
|
| 149 |
+
let result;
|
| 150 |
+
|
| 151 |
+
if (type === "image") {
|
| 152 |
+
const neg = document.getElementById("gen-negative").value;
|
| 153 |
+
result = await client.predict(endpoint, { prompt, negative_prompt: neg, steps: 4 });
|
| 154 |
+
} else {
|
| 155 |
+
result = await client.predict(endpoint, { prompt, text: prompt });
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const fileData = result.data[0];
|
| 159 |
+
if (fileData && fileData.url) {
|
| 160 |
+
showResult(fileData, type);
|
| 161 |
+
toast("Generated!", "success");
|
| 162 |
+
} else {
|
| 163 |
+
genPreview.innerHTML = '<div class="preview-placeholder"><span>❌</span><p>Generation failed</p></div>';
|
| 164 |
+
}
|
| 165 |
+
} catch (e) {
|
| 166 |
+
genPreview.innerHTML = `<div class="preview-placeholder"><span>❌</span><p>${e.message}</p></div>`;
|
| 167 |
+
} finally {
|
| 168 |
+
btnText.style.display = "inline";
|
| 169 |
+
btnLoad.style.display = "none";
|
| 170 |
+
genBtn.disabled = false;
|
| 171 |
+
}
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
function showResult(fileData, type) {
|
| 175 |
+
const url = fileData.url;
|
| 176 |
+
let html = "";
|
| 177 |
+
|
| 178 |
+
if (type === "image") {
|
| 179 |
+
html = `<img src="${url}" alt="Generated">`;
|
| 180 |
+
} else if (type === "video") {
|
| 181 |
+
html = `<video controls autoplay><source src="${url}" type="video/mp4"></video>`;
|
| 182 |
+
} else if (["voice", "music", "sfx"].includes(type)) {
|
| 183 |
+
html = `<audio controls autoplay><source src="${url}"></audio>`;
|
| 184 |
+
} else {
|
| 185 |
+
html = `<div class="preview-placeholder"><span>📦</span><p>3D Model Generated</p></div>`;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
genPreview.innerHTML = html;
|
| 189 |
+
genInfo.style.display = "flex";
|
| 190 |
+
|
| 191 |
+
document.getElementById("gen-download").onclick = () => {
|
| 192 |
+
const a = document.createElement("a");
|
| 193 |
+
a.href = url;
|
| 194 |
+
a.download = `gameforge_${type}_${Date.now()}`;
|
| 195 |
+
a.click();
|
| 196 |
+
};
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
// ============================================================================
|
| 200 |
+
// Models
|
| 201 |
+
// ============================================================================
|
| 202 |
+
|
| 203 |
+
async function loadModels() {
|
| 204 |
+
if (!registryData.length) {
|
| 205 |
+
const reg = await api("/registry_info");
|
| 206 |
+
registryData = reg.models || [];
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const typeFilter = document.getElementById("model-filter-type");
|
| 210 |
+
const freeOnly = document.getElementById("model-filter-free");
|
| 211 |
+
const safeOnly = document.getElementById("model-filter-safe");
|
| 212 |
+
|
| 213 |
+
// Populate type filter
|
| 214 |
+
const types = [...new Set(registryData.map(m => m.asset_type))];
|
| 215 |
+
if (typeFilter.options.length <= 1) {
|
| 216 |
+
types.forEach(t => {
|
| 217 |
+
const opt = document.createElement("option");
|
| 218 |
+
opt.value = t;
|
| 219 |
+
opt.textContent = t;
|
| 220 |
+
typeFilter.appendChild(opt);
|
| 221 |
+
});
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function render() {
|
| 225 |
+
let models = registryData;
|
| 226 |
+
if (typeFilter.value) models = models.filter(m => m.asset_type === typeFilter.value);
|
| 227 |
+
if (freeOnly.checked) models = models.filter(m => m.free);
|
| 228 |
+
if (safeOnly.checked) models = models.filter(m => m.commercial_safe);
|
| 229 |
+
|
| 230 |
+
const tbody = document.getElementById("models-tbody");
|
| 231 |
+
tbody.innerHTML = models.map(m => `
|
| 232 |
+
<tr>
|
| 233 |
+
<td>${m.asset_type}</td>
|
| 234 |
+
<td>${m.variant}</td>
|
| 235 |
+
<td style="font-family:monospace;font-size:12px">${m.model}</td>
|
| 236 |
+
<td>${m.type}</td>
|
| 237 |
+
<td>${m.license}</td>
|
| 238 |
+
<td>${m.hardware}</td>
|
| 239 |
+
<td><span class="badge ${m.free ? 'badge-free' : 'badge-paid'}">${m.free ? 'FREE' : 'PAID'}</span></td>
|
| 240 |
+
<td><span class="badge ${m.commercial_safe ? 'badge-safe' : 'badge-check'}">${m.commercial_safe ? 'SAFE' : 'CHECK'}</span></td>
|
| 241 |
+
</tr>
|
| 242 |
+
`).join("");
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
typeFilter.onchange = freeOnly.onchange = safeOnly.onchange = render;
|
| 246 |
+
render();
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// ============================================================================
|
| 250 |
+
// Pipelines
|
| 251 |
+
// ============================================================================
|
| 252 |
+
|
| 253 |
+
async function loadPipelines() {
|
| 254 |
+
if (!pipelines.length) {
|
| 255 |
+
pipelines = await api("/list_pipelines");
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const grid = document.getElementById("pipeline-grid");
|
| 259 |
+
grid.innerHTML = pipelines.map(p => `
|
| 260 |
+
<div class="pipeline-card">
|
| 261 |
+
<h3>${p.name}</h3>
|
| 262 |
+
<p>${p.description}</p>
|
| 263 |
+
<div class="pipeline-meta">
|
| 264 |
+
<span>📋 ${p.steps} steps</span>
|
| 265 |
+
<span>v${p.version}</span>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
`).join("");
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// ============================================================================
|
| 272 |
+
// Assets
|
| 273 |
+
// ============================================================================
|
| 274 |
+
|
| 275 |
+
async function loadAssets() {
|
| 276 |
+
const assets = await api("/list_assets");
|
| 277 |
+
const grid = document.getElementById("assets-grid");
|
| 278 |
+
|
| 279 |
+
if (!assets.length) {
|
| 280 |
+
grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No assets generated yet. Go to Generate to create some!</p>';
|
| 281 |
+
return;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
grid.innerHTML = assets.map(a => `
|
| 285 |
+
<div class="asset-card">
|
| 286 |
+
<div class="name">${a.name}</div>
|
| 287 |
+
<div class="meta">${a.format} · ${(a.size / 1024).toFixed(1)} KB</div>
|
| 288 |
+
</div>
|
| 289 |
+
`).join("");
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
document.getElementById("asset-refresh")?.addEventListener("click", loadAssets);
|
| 293 |
+
|
| 294 |
+
// ============================================================================
|
| 295 |
+
// Batch
|
| 296 |
+
// ============================================================================
|
| 297 |
+
|
| 298 |
+
async function loadBatchPipelines() {
|
| 299 |
+
if (!pipelines.length) {
|
| 300 |
+
pipelines = await api("/list_pipelines");
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
const select = document.getElementById("batch-pipeline");
|
| 304 |
+
select.innerHTML = pipelines.map(p =>
|
| 305 |
+
`<option value="${p.name}">${p.name} (${p.steps} steps)</option>`
|
| 306 |
+
).join("");
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
document.getElementById("batch-count")?.addEventListener("input", (e) => {
|
| 310 |
+
document.getElementById("batch-count-val").textContent = e.target.value;
|
| 311 |
+
});
|
| 312 |
+
|
| 313 |
+
document.getElementById("batch-btn")?.addEventListener("click", async () => {
|
| 314 |
+
const pipeline = document.getElementById("batch-pipeline").value;
|
| 315 |
+
const prompt = document.getElementById("batch-prompt").value;
|
| 316 |
+
const count = parseInt(document.getElementById("batch-count").value);
|
| 317 |
+
|
| 318 |
+
if (!prompt) { toast("Enter a prompt", "error"); return; }
|
| 319 |
+
|
| 320 |
+
toast(`Generating ${count} variants...`, "info");
|
| 321 |
+
|
| 322 |
+
// Run variants sequentially
|
| 323 |
+
const results = document.getElementById("batch-results");
|
| 324 |
+
results.innerHTML = "";
|
| 325 |
+
|
| 326 |
+
for (let i = 0; i < count; i++) {
|
| 327 |
+
const card = document.createElement("div");
|
| 328 |
+
card.className = "asset-card";
|
| 329 |
+
card.innerHTML = `<div class="name">Variant ${i + 1}</div><div class="meta">Generating...</div>`;
|
| 330 |
+
results.appendChild(card);
|
| 331 |
+
|
| 332 |
+
try {
|
| 333 |
+
// Each variant gets a slightly different prompt
|
| 334 |
+
const variantPrompt = `${prompt}, style variation ${i + 1}`;
|
| 335 |
+
// This would call the pipeline endpoint
|
| 336 |
+
card.querySelector(".meta").textContent = "Queued";
|
| 337 |
+
} catch (e) {
|
| 338 |
+
card.querySelector(".meta").textContent = "Failed: " + e.message;
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
// ============================================================================
|
| 344 |
+
// Toast Notifications
|
| 345 |
+
// ============================================================================
|
| 346 |
+
|
| 347 |
+
function toast(message, type = "info") {
|
| 348 |
+
const container = document.getElementById("toast-container");
|
| 349 |
+
const el = document.createElement("div");
|
| 350 |
+
el.className = `toast toast-${type}`;
|
| 351 |
+
el.textContent = message;
|
| 352 |
+
container.appendChild(el);
|
| 353 |
+
setTimeout(() => el.remove(), 4000);
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// ============================================================================
|
| 357 |
+
// Init
|
| 358 |
+
// ============================================================================
|
| 359 |
+
|
| 360 |
+
connect();
|