jkorstad commited on
Commit
a185655
·
verified ·
1 Parent(s): f0da9c5

GameForge v0.2.0 - gradio.Server with custom frontend + ZeroGPU

Browse files
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: Gameforge
3
- emoji: 🏆
4
- colorFrom: blue
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.12.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
- short_description: Open Source Asset Creation Platform
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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} &middot; ${(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();