Spaces:
Running on Zero
Running on Zero
Support fully custom asset manifests
Browse files
README.md
CHANGED
|
@@ -11,46 +11,73 @@ pinned: false
|
|
| 11 |
|
| 12 |
# Image Generator for HTML Games
|
| 13 |
|
| 14 |
-
Paste an HTML game, select its game type and
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
## Deterministic asset contract
|
| 17 |
|
| 18 |
-
Reference each requested
|
| 19 |
|
| 20 |
```js
|
| 21 |
-
const
|
| 22 |
-
|
| 23 |
|
| 24 |
-
const
|
| 25 |
-
|
| 26 |
```
|
| 27 |
|
| 28 |
-
The generator injects an immutable `window.GAME_ASSETS` manifest whose keys are normalized
|
| 29 |
-
|
| 30 |
-
If a requested role has no deterministic hook, assets are still generated for review, but no rewritten game is returned. The status explains which hooks are missing and the preview is explicitly the unchanged original game.
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
```text
|
| 37 |
-
player: agile desert ranger with a leather bow
|
| 38 |
-
enemy_grunt: compact orange patrol robot
|
| 39 |
-
enemy_boss: towering red armored commander
|
| 40 |
-
npc_merchant: elderly traveling potion seller
|
| 41 |
-
```
|
| 42 |
|
| 43 |
-
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
-
The UI reports the configured prompt and image pipeline and labels every
|
| 48 |
|
| 49 |
-
The public production path uses diffusion rather than procedural drawing. Diffusion provides much better open-ended subject and style fidelity but can still miss perspective,
|
| 50 |
|
| 51 |
-
The app combines the game type, perspective, theme,
|
| 52 |
|
| 53 |
-
If the Space has an `HF_TOKEN` secret and `USE_HF_PROMPT_PROVIDER=1`, it first uses the text model in `HF_PROMPT_MODEL` to interpret the code,
|
| 54 |
|
| 55 |
## Default neural image pipeline
|
| 56 |
|
|
@@ -70,7 +97,7 @@ The deployed Space is fail-closed: if the primary neural model cannot run, gener
|
|
| 70 |
|
| 71 |
ZeroGPU is free for eligible personal accounts, but it is quota-limited rather than unlimited: free accounts currently receive five minutes of GPU time per day. Queueing or a quota message is therefore possible even though no inference credits or payment are required.
|
| 72 |
|
| 73 |
-
Initial generation
|
| 74 |
|
| 75 |
The primary pipeline can be configured with:
|
| 76 |
|
|
|
|
| 11 |
|
| 12 |
# Image Generator for HTML Games
|
| 13 |
|
| 14 |
+
Paste an HTML game, select its game type and shared perspective, and define exactly which images the game needs through a user-authored asset manifest. Asset IDs are not restricted to players or backgrounds. The Space rewrites the game only when every requested asset set has a deterministic integration point.
|
| 15 |
+
|
| 16 |
+
## Custom asset manifest
|
| 17 |
+
|
| 18 |
+
The recommended JSON input makes generation behavior explicit instead of guessing from asset names:
|
| 19 |
+
|
| 20 |
+
```json
|
| 21 |
+
{
|
| 22 |
+
"assets": [
|
| 23 |
+
{
|
| 24 |
+
"id": "crystal_pickups",
|
| 25 |
+
"description": "distinct glowing mineral pickups",
|
| 26 |
+
"quantity": 3,
|
| 27 |
+
"width": 96,
|
| 28 |
+
"height": 96,
|
| 29 |
+
"transparent": true,
|
| 30 |
+
"composition": "single_subject",
|
| 31 |
+
"camera": "isometric",
|
| 32 |
+
"variations": "different silhouette and mineral color"
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"id": "desert_outpost",
|
| 36 |
+
"description": "abandoned science-fantasy outpost with readable traversal space",
|
| 37 |
+
"quantity": 1,
|
| 38 |
+
"width": 1280,
|
| 39 |
+
"height": 720,
|
| 40 |
+
"transparent": false,
|
| 41 |
+
"composition": "full_frame",
|
| 42 |
+
"camera": "top-down"
|
| 43 |
+
}
|
| 44 |
+
]
|
| 45 |
+
}
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Supported technical compositions are `single_subject`, `full_frame`, `seamless`, `icon`, `animation_frame`, `sprite_sheet`, and `freeform`. Optional `expected_subjects` and `silhouette: "humanoid"` fields enable stricter validation when appropriate. The older `name: description` syntax remains available for compatibility, but the JSON contract is preferred because it does not infer processing rules from names.
|
| 49 |
+
|
| 50 |
+
Each requested quantity is generated through an independent model call and saved as a separate PNG. A quantity of three produces keys such as `crystal_pickups_01` through `crystal_pickups_03`, plus a grouped array at `GAME_ASSETS.crystal_pickups`. The free ZeroGPU batch is capped by `MAX_GENERATED_ASSETS` (default eight outputs); larger libraries should be produced in multiple batches.
|
| 51 |
|
| 52 |
## Deterministic asset contract
|
| 53 |
|
| 54 |
+
Reference each requested asset with either an exact filename or an explicit `GAME_ASSETS` hook:
|
| 55 |
|
| 56 |
```js
|
| 57 |
+
const pickupImage = new Image();
|
| 58 |
+
pickupImage.src = GAME_ASSETS.crystal_pickups[0];
|
| 59 |
|
| 60 |
+
const outpostImage = new Image();
|
| 61 |
+
outpostImage.src = GAME_ASSETS.desert_outpost; // or "sprite_desert_outpost.png"
|
| 62 |
```
|
| 63 |
|
| 64 |
+
The generator injects an immutable `window.GAME_ASSETS` manifest whose keys are normalized asset IDs. Single-output IDs contain a data URI; multi-output IDs contain an ordered array and also expose individual numbered keys. It does not monkey-patch browser image APIs, guess aliases, or silently replace canvas primitives such as `fillRect()`, `arc()`, and `fill()`.
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
If a requested asset has no deterministic hook, images are still generated for review, but no rewritten game is returned. The status explains which hooks are missing and the preview is explicitly the unchanged original game.
|
| 67 |
|
| 68 |
+
## Contract-driven generation and validation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
+
Filenames and IDs do not decide whether an image is a scene or sprite. Dimensions, transparency, composition, camera, quantity, subject count, and silhouette requirements come from the manifest. Transparent single-subject outputs receive alpha extraction, occupancy normalization, and subject-count checks. Opaque outputs are checked for unwanted transparency. Seamless requests receive an opposite-edge similarity check. Other compositions are not incorrectly rejected for containing multiple subjects.
|
| 71 |
|
| 72 |
+
The image model can still violate these instructions, so every result requires visual review. Single-subject outputs that fail automated checks are regenerated with a fresh seed rather than cropped into a guessed subject. The review UI can regenerate one output or a complete multi-output asset set.
|
| 73 |
|
| 74 |
+
The UI reports the configured prompt and image pipeline and labels every output with its actual source. Every asset carries a normalized camera contract (`top_down`, `side_view`, `isometric`, `first_person`, or `front_view`) or retains an explicit custom camera instruction. Top-down prompts explicitly forbid a sky, horizon, eye-level view, and front-facing facades.
|
| 75 |
|
| 76 |
+
The public production path uses diffusion rather than procedural drawing. Diffusion provides much better open-ended subject and style fidelity but can still miss perspective, alpha, seamless-edge, or layout constraints. Negative prompts are selected from the explicit composition contract; full-frame and freeform images are not forced to be empty environments, while single-subject outputs reject contact sheets and duplicates. A procedural renderer remains available only for local development when fail-closed production mode is disabled.
|
| 77 |
|
| 78 |
+
The app combines the game type, perspective, theme, pasted code, and manifest into explicit per-output image prompts, then generates PNG assets and embeds contract-compatible assets as base64 data URIs.
|
| 79 |
|
| 80 |
+
If the Space has an `HF_TOKEN` secret and `USE_HF_PROMPT_PROVIDER=1`, it first uses the text model in `HF_PROMPT_MODEL` to interpret the code, requested assets, and theme into image prompts. The default prompt model is `Qwen/Qwen2.5-Coder-7B-Instruct`. The technical output contract is appended after model interpretation, so the model cannot silently remove it.
|
| 81 |
|
| 82 |
## Default neural image pipeline
|
| 83 |
|
|
|
|
| 97 |
|
| 98 |
ZeroGPU is free for eligible personal accounts, but it is quota-limited rather than unlimited: free accounts currently receive five minutes of GPU time per day. Queueing or a quota message is therefore possible even though no inference credits or payment are required.
|
| 99 |
|
| 100 |
+
Initial generation and regeneration use dynamic ZeroGPU duration estimates based on the number of independent outputs. This changes only scheduler reservation and queue priority; it does not reduce diffusion steps or image quality. The estimator avoids rejecting short jobs merely because an unnecessarily large fixed duration exceeds the visitor's remaining quota.
|
| 101 |
|
| 102 |
The primary pipeline can be configured with:
|
| 103 |
|
app.py
CHANGED
|
@@ -45,9 +45,9 @@ STARTER_HTML = """<!DOCTYPE html>
|
|
| 45 |
const ctx = canvas.getContext("2d");
|
| 46 |
|
| 47 |
const background = new Image();
|
| 48 |
-
background.src = "
|
| 49 |
const playerImg = new Image();
|
| 50 |
-
playerImg.src = "
|
| 51 |
|
| 52 |
const keys = new Set();
|
| 53 |
const player = { x: 380, y: 205, w: 48, h: 48, speed: 4 };
|
|
@@ -83,13 +83,46 @@ STARTER_HTML = """<!DOCTYPE html>
|
|
| 83 |
</html>"""
|
| 84 |
|
| 85 |
|
| 86 |
-
DEFAULT_ROLES = """
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
@dataclass
|
|
@@ -100,6 +133,15 @@ class AssetSpec:
|
|
| 100 |
width: int
|
| 101 |
height: int
|
| 102 |
camera: str = "auto"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
@dataclass
|
|
@@ -336,30 +378,78 @@ def interpret_style_hint(style_hint: str) -> StylePlan:
|
|
| 336 |
return StylePlan(medium, palette, texture, lighting, linework, camera, tuple(tags))
|
| 337 |
|
| 338 |
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
plan = interpret_style_hint(style_hint)
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
"Present one complete body in one pose from the required camera direction. Make the visible subject fill "
|
| 354 |
-
"about eighty percent of the frame with modest even margins. Use a clean uniform white studio field and "
|
| 355 |
-
"a readable game-scale silhouette."
|
| 356 |
-
)
|
| 357 |
return (
|
| 358 |
f"{role} asset: {prompt}. Creative brief: {style_hint}. "
|
| 359 |
f"Style interpretation: {plan.medium}; {plan.palette}; "
|
| 360 |
f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. "
|
| 361 |
-
f"Camera contract: {
|
| 362 |
-
f"{
|
|
|
|
| 363 |
"Game asset, readable at small size, with a clean unlabeled presentation."
|
| 364 |
)
|
| 365 |
|
|
@@ -376,22 +466,63 @@ def build_style_context(game_type: str, perspective: str, theme: str) -> str:
|
|
| 376 |
return ". ".join(parts) or "cohesive game-ready 2D art"
|
| 377 |
|
| 378 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
def parse_role_lines(raw_roles: str) -> list[tuple[str, str]]:
|
|
|
|
| 380 |
parsed: list[tuple[str, str]] = []
|
| 381 |
-
for
|
| 382 |
-
|
| 383 |
-
if not line or line.startswith("#"):
|
| 384 |
-
continue
|
| 385 |
-
|
| 386 |
-
if ":" in line:
|
| 387 |
-
role, prompt = line.split(":", 1)
|
| 388 |
-
elif "=" in line:
|
| 389 |
-
role, prompt = line.split("=", 1)
|
| 390 |
-
else:
|
| 391 |
-
role, prompt = line, line
|
| 392 |
-
role = role.strip()
|
| 393 |
-
prompt = prompt.strip() or role
|
| 394 |
-
parsed.append((role, prompt))
|
| 395 |
return parsed
|
| 396 |
|
| 397 |
|
|
@@ -463,7 +594,8 @@ def infer_code_context(html_code: str) -> str:
|
|
| 463 |
|
| 464 |
|
| 465 |
def local_prompt_map(role_lines: list[tuple[str, str]], style_hint: str) -> dict[str, str]:
|
| 466 |
-
|
|
|
|
| 467 |
|
| 468 |
|
| 469 |
def extract_json_object(text: str) -> dict | None:
|
|
@@ -495,12 +627,11 @@ def hf_prompt_json(html_code: str, role_lines: list[tuple[str, str]], style_hint
|
|
| 495 |
role_block = "\n".join(f"- {role}: {prompt}" for role, prompt in role_lines)
|
| 496 |
instruction = (
|
| 497 |
"You are a senior game art director and prompt engineer. Read the HTML game context, "
|
| 498 |
-
"the requested
|
| 499 |
"each key is the exact role name and each value is one concise text-to-image prompt. "
|
| 500 |
-
"
|
| 501 |
-
"
|
| 502 |
-
"
|
| 503 |
-
"Describe only one role per prompt; never combine multiple character roles into one image."
|
| 504 |
)
|
| 505 |
user_text = (
|
| 506 |
f"HTML/game context summary: {infer_code_context(html_code)}\n\n"
|
|
@@ -572,40 +703,131 @@ def parse_assets(
|
|
| 572 |
prompt_map: dict[str, str] | None = None,
|
| 573 |
role_lines: list[tuple[str, str]] | None = None,
|
| 574 |
) -> list[AssetSpec]:
|
|
|
|
|
|
|
| 575 |
specs: list[AssetSpec] = []
|
| 576 |
for role, prompt in role_lines if role_lines is not None else parse_role_lines(raw_roles):
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
)
|
|
|
|
|
|
|
| 590 |
else:
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
)
|
| 602 |
return specs
|
| 603 |
|
| 604 |
|
| 605 |
def is_background_spec(spec: AssetSpec) -> bool:
|
| 606 |
-
|
| 607 |
-
return
|
| 608 |
-
|
|
|
|
| 609 |
)
|
| 610 |
|
| 611 |
|
|
@@ -1620,23 +1842,23 @@ def diffusion_dimensions(spec: AssetSpec) -> tuple[int, int]:
|
|
| 1620 |
|
| 1621 |
|
| 1622 |
def diffusion_negative_prompt(spec: AssetSpec) -> str:
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
return (
|
| 1630 |
-
"
|
| 1631 |
-
"
|
| 1632 |
-
"
|
| 1633 |
+ camera_negative
|
| 1634 |
)
|
| 1635 |
-
|
| 1636 |
-
"
|
| 1637 |
-
|
| 1638 |
-
"
|
| 1639 |
-
|
| 1640 |
|
| 1641 |
|
| 1642 |
def build_camera_control_image(spec: AssetSpec, width: int = 1024, height: int = 576) -> Image.Image:
|
|
@@ -1728,18 +1950,38 @@ def initialize_primary_image_model() -> None:
|
|
| 1728 |
|
| 1729 |
def primary_diffusion_prompt(spec: AssetSpec) -> str:
|
| 1730 |
camera = primary_camera_phrase(spec.camera, is_background_spec(spec))
|
| 1731 |
-
if
|
| 1732 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1733 |
return (
|
| 1734 |
-
f"{camera}
|
| 1735 |
-
f"
|
|
|
|
| 1736 |
)
|
| 1737 |
-
description = compact_prompt_words(spec.prompt,
|
| 1738 |
-
|
| 1739 |
-
|
| 1740 |
-
|
| 1741 |
-
|
| 1742 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1743 |
|
| 1744 |
|
| 1745 |
def primary_diffusion_png(spec: AssetSpec, index: int, run_id: int) -> tuple[bytes | None, str | None]:
|
|
@@ -1756,7 +1998,7 @@ def primary_diffusion_png(spec: AssetSpec, index: int, run_id: int) -> tuple[byt
|
|
| 1756 |
|
| 1757 |
is_background = is_background_spec(spec)
|
| 1758 |
attempts = 1 if is_background else PRIMARY_SPRITE_ATTEMPTS
|
| 1759 |
-
width, height = (
|
| 1760 |
last_failure_detail = "the last output did not contain one valid foreground subject"
|
| 1761 |
for attempt in range(attempts):
|
| 1762 |
seed = abs(hash(f"primary|{spec.role}|{spec.prompt}|{index}|{run_id}|{attempt}")) % 2147483647
|
|
@@ -1851,10 +2093,9 @@ def controlnet_background_png(spec: AssetSpec, index: int, run_id: int) -> tuple
|
|
| 1851 |
|
| 1852 |
def polish_diffusion_asset(image: Image.Image, spec: AssetSpec) -> bytes:
|
| 1853 |
image = image.convert("RGBA")
|
| 1854 |
-
if
|
| 1855 |
# Text-to-image models do not produce real transparency. This makes sprites
|
| 1856 |
-
#
|
| 1857 |
-
# Preserve the model's portrait composition instead of stretching it square.
|
| 1858 |
corner_source = image.resize((64, 64), Image.LANCZOS)
|
| 1859 |
source_corners = [
|
| 1860 |
corner_source.getpixel((0, 0)),
|
|
@@ -1864,15 +2105,15 @@ def polish_diffusion_asset(image: Image.Image, spec: AssetSpec) -> bytes:
|
|
| 1864 |
]
|
| 1865 |
source_bg = tuple(sum(pixel[i] for pixel in source_corners) // len(source_corners) for i in range(3))
|
| 1866 |
contained = image.copy()
|
| 1867 |
-
contained.thumbnail((
|
| 1868 |
-
small = Image.new("RGBA", (
|
| 1869 |
-
offset = ((
|
| 1870 |
small.alpha_composite(contained, dest=offset)
|
| 1871 |
corners = [
|
| 1872 |
small.getpixel((0, 0)),
|
| 1873 |
-
small.getpixel((
|
| 1874 |
-
small.getpixel((0,
|
| 1875 |
-
small.getpixel((
|
| 1876 |
]
|
| 1877 |
bg = tuple(sum(pixel[i] for pixel in corners) // len(corners) for i in range(3))
|
| 1878 |
pixels = small.load()
|
|
@@ -1886,7 +2127,11 @@ def polish_diffusion_asset(image: Image.Image, spec: AssetSpec) -> bytes:
|
|
| 1886 |
elif dist < 125:
|
| 1887 |
a = max(0, min(a, (dist - 64) * 4))
|
| 1888 |
pixels[x, y] = (r, g, b, a)
|
| 1889 |
-
image =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1890 |
else:
|
| 1891 |
image = image.resize((spec.width, spec.height), Image.LANCZOS)
|
| 1892 |
out = io.BytesIO()
|
|
@@ -2066,7 +2311,7 @@ def estimated_foreground_subject_count(content: bytes) -> int:
|
|
| 2066 |
|
| 2067 |
def has_implausibly_thin_foreground_subject(content: bytes, spec: AssetSpec) -> bool:
|
| 2068 |
"""Reject collapsed humanoid silhouettes without penalizing projectiles or narrow props."""
|
| 2069 |
-
if
|
| 2070 |
return False
|
| 2071 |
components = foreground_component_geometry(content)
|
| 2072 |
if not components:
|
|
@@ -2276,10 +2521,15 @@ def replacement_names(spec: AssetSpec) -> set[str]:
|
|
| 2276 |
|
| 2277 |
def code_references_role(html_code: str, spec: AssetSpec) -> bool:
|
| 2278 |
slug = slugify(spec.role)
|
| 2279 |
-
|
|
|
|
| 2280 |
hook_patterns = (
|
| 2281 |
-
|
| 2282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2283 |
)
|
| 2284 |
if any(re.search(pattern, html_code, flags=re.I) for pattern in hook_patterns):
|
| 2285 |
return True
|
|
@@ -2323,14 +2573,14 @@ def validate_asset_png(content: bytes, spec: AssetSpec) -> list[str]:
|
|
| 2323 |
warnings.append(f"unexpected dimensions {image.width}x{image.height}")
|
| 2324 |
alpha = image.getchannel("A")
|
| 2325 |
extrema = alpha.getextrema()
|
| 2326 |
-
if
|
| 2327 |
if extrema[0] < 255:
|
| 2328 |
-
warnings.append("
|
| 2329 |
else:
|
| 2330 |
transparent_pixels = sum(alpha.histogram()[:16])
|
| 2331 |
transparent_ratio = transparent_pixels / max(1, image.width * image.height)
|
| 2332 |
if transparent_ratio < 0.12:
|
| 2333 |
-
warnings.append("
|
| 2334 |
corners = (
|
| 2335 |
alpha.getpixel((0, 0)),
|
| 2336 |
alpha.getpixel((image.width - 1, 0)),
|
|
@@ -2338,16 +2588,29 @@ def validate_asset_png(content: bytes, spec: AssetSpec) -> list[str]:
|
|
| 2338 |
alpha.getpixel((image.width - 1, image.height - 1)),
|
| 2339 |
)
|
| 2340 |
if any(value > 16 for value in corners):
|
| 2341 |
-
warnings.append("
|
| 2342 |
-
|
| 2343 |
-
|
| 2344 |
-
|
| 2345 |
-
|
| 2346 |
-
|
| 2347 |
-
|
| 2348 |
-
|
| 2349 |
-
|
| 2350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2351 |
return warnings
|
| 2352 |
|
| 2353 |
|
|
@@ -2358,12 +2621,16 @@ def embed_assets(html_code: str, assets: dict[str, str], specs: list[AssetSpec])
|
|
| 2358 |
|
| 2359 |
output = html_code
|
| 2360 |
manifest_lines = ["<!-- Embedded game assets generated by Image Generator for HTML Games"]
|
| 2361 |
-
asset_map: dict[str, str] = {}
|
|
|
|
| 2362 |
|
| 2363 |
for spec in specs:
|
| 2364 |
data_uri = assets[spec.role]
|
| 2365 |
slug = slugify(spec.role)
|
| 2366 |
-
|
|
|
|
|
|
|
|
|
|
| 2367 |
manifest_lines.append(f"{spec.role}: {spec.filename}")
|
| 2368 |
for name in replacement_names(spec):
|
| 2369 |
output = output.replace(f'"{name}"', f'"{data_uri}"')
|
|
@@ -2371,12 +2638,19 @@ def embed_assets(html_code: str, assets: dict[str, str], specs: list[AssetSpec])
|
|
| 2371 |
if name.startswith("{"):
|
| 2372 |
output = output.replace(name, data_uri)
|
| 2373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2374 |
manifest_lines.append("-->")
|
| 2375 |
manifest = "\n".join(manifest_lines) + "\n"
|
| 2376 |
asset_json = json.dumps(asset_map)
|
| 2377 |
helper_script = f"""<script>
|
| 2378 |
(function () {{
|
| 2379 |
var ASSETS = {asset_json};
|
|
|
|
|
|
|
|
|
|
| 2380 |
window.GAME_ASSETS = Object.freeze(Object.assign({{}}, window.GAME_ASSETS || {{}}, ASSETS));
|
| 2381 |
window.GENERATED_GAME_ASSETS = ASSETS;
|
| 2382 |
}})();
|
|
@@ -2482,12 +2756,13 @@ def model_configuration_summary() -> str:
|
|
| 2482 |
readiness = "loads in the deployed ZeroGPU runtime"
|
| 2483 |
return (
|
| 2484 |
"**Configured model pipeline:** "
|
| 2485 |
-
f"prompts: `{prompt_source}` ·
|
|
|
|
| 2486 |
f"remote image fallback: `{remote_fallback}` · neural image models: `{neural_status}` · "
|
| 2487 |
f"neural enforcement: `{enforcement}` · primary readiness: `{readiness}`. "
|
| 2488 |
"Every production image is generated directly from its written prompt by the primary text-to-image model. "
|
| 2489 |
-
"
|
| 2490 |
-
"
|
| 2491 |
"silhouette, and scale validation; failures are regenerated with a new seed up to "
|
| 2492 |
f"{PRIMARY_SPRITE_ATTEMPTS} times. No procedural guide is supplied to the model. The procedural renderer is "
|
| 2493 |
"development-only and is blocked in the deployed Space when the primary model fails."
|
|
@@ -2518,18 +2793,19 @@ def render_generation_state(state: dict, action: str, errors: list[str] | None =
|
|
| 2518 |
(spec.role, state["prompt_model"], state["image_models"][spec.role])
|
| 2519 |
for spec in specs
|
| 2520 |
]
|
|
|
|
| 2521 |
if integration.supported:
|
| 2522 |
rewritten = embed_assets(html_code, assets, specs)
|
| 2523 |
preview_html = build_preview(rewritten)
|
| 2524 |
status = (
|
| 2525 |
-
f"{action} {len(specs)} asset(s) and embedded them through the deterministic "
|
| 2526 |
f"GAME_ASSETS contract using {summarize_model_sources(model_rows)}."
|
| 2527 |
)
|
| 2528 |
else:
|
| 2529 |
rewritten = ""
|
| 2530 |
preview_html = build_preview(html_code)
|
| 2531 |
status = (
|
| 2532 |
-
f"{action} {len(specs)} asset(s), but did not rewrite the game because deterministic "
|
| 2533 |
"asset hooks are missing. The preview below is the unchanged original game."
|
| 2534 |
)
|
| 2535 |
if integration.warnings:
|
|
@@ -2537,7 +2813,7 @@ def render_generation_state(state: dict, action: str, errors: list[str] | None =
|
|
| 2537 |
inferred_roles = state.get("inferred_roles", [])
|
| 2538 |
if inferred_roles:
|
| 2539 |
status += (
|
| 2540 |
-
"\n\n- Added
|
| 2541 |
+ ", ".join(inferred_roles)
|
| 2542 |
+ ". Add explicit descriptions for these roles to improve art direction."
|
| 2543 |
)
|
|
@@ -2581,9 +2857,16 @@ def estimate_generation_gpu_duration(
|
|
| 2581 |
theme: str,
|
| 2582 |
) -> int:
|
| 2583 |
"""Reserve realistic ZeroGPU time based on the number of independent assets."""
|
| 2584 |
-
|
| 2585 |
-
|
| 2586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2587 |
|
| 2588 |
|
| 2589 |
@gpu_task(duration=estimate_generation_gpu_duration)
|
|
@@ -2598,15 +2881,18 @@ def generate_images_and_game(
|
|
| 2598 |
return empty_generation_result("Paste HTML game code first.")
|
| 2599 |
|
| 2600 |
style_context = build_style_context(game_type, perspective, theme)
|
| 2601 |
-
|
| 2602 |
-
|
| 2603 |
-
|
| 2604 |
-
|
| 2605 |
-
|
| 2606 |
-
|
|
|
|
|
|
|
|
|
|
| 2607 |
if not specs:
|
| 2608 |
return empty_generation_result(
|
| 2609 |
-
"Add at least one asset
|
| 2610 |
html_code,
|
| 2611 |
)
|
| 2612 |
slugs = [slugify(spec.role) for spec in specs]
|
|
@@ -2650,16 +2936,38 @@ def generate_images_and_game(
|
|
| 2650 |
"run_id": run_id,
|
| 2651 |
}
|
| 2652 |
rendered = render_generation_state(state, "Generated", errors)
|
| 2653 |
-
role_choices =
|
|
|
|
| 2654 |
return (
|
| 2655 |
*rendered,
|
| 2656 |
state,
|
| 2657 |
-
gr.Dropdown(choices=role_choices, value=
|
| 2658 |
-
gr.CheckboxGroup(choices=
|
| 2659 |
)
|
| 2660 |
|
| 2661 |
|
| 2662 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2663 |
def regenerate_selected_asset(state: dict, selected_role: str, approved_roles: list[str]):
|
| 2664 |
if not state or not selected_role:
|
| 2665 |
return (
|
|
@@ -2674,34 +2982,43 @@ def regenerate_selected_asset(state: dict, selected_role: str, approved_roles: l
|
|
| 2674 |
gr.CheckboxGroup(choices=[], value=[]),
|
| 2675 |
)
|
| 2676 |
specs = state["specs"]
|
| 2677 |
-
|
| 2678 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2679 |
rendered = render_generation_state(state, "Kept")
|
| 2680 |
roles = [spec.role for spec in specs]
|
| 2681 |
return (*rendered, state, gr.CheckboxGroup(choices=roles, value=approved_roles or []))
|
| 2682 |
|
| 2683 |
-
spec = specs[index]
|
| 2684 |
run_id = time.time_ns()
|
| 2685 |
-
|
| 2686 |
-
|
| 2687 |
-
|
| 2688 |
-
|
| 2689 |
-
|
| 2690 |
-
|
| 2691 |
-
|
| 2692 |
-
state,
|
| 2693 |
-
|
| 2694 |
-
|
| 2695 |
-
|
| 2696 |
-
|
| 2697 |
-
|
| 2698 |
-
|
| 2699 |
-
|
|
|
|
|
|
|
|
|
|
| 2700 |
state["run_id"] = run_id
|
| 2701 |
-
|
| 2702 |
-
|
|
|
|
|
|
|
|
|
|
| 2703 |
roles = [item.role for item in specs]
|
| 2704 |
-
retained_approvals = [role for role in (approved_roles or []) if role
|
| 2705 |
return (
|
| 2706 |
*rendered,
|
| 2707 |
state,
|
|
@@ -2880,9 +3197,9 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
|
|
| 2880 |
<header class="hero">
|
| 2881 |
<div class="eyebrow">Game asset studio</div>
|
| 2882 |
<h1>Turn your game code into a visual world.</h1>
|
| 2883 |
-
<p>
|
| 2884 |
<div class="hero-chips">
|
| 2885 |
-
<span>
|
| 2886 |
</div>
|
| 2887 |
</header>
|
| 2888 |
"""
|
|
@@ -2892,7 +3209,7 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
|
|
| 2892 |
|
| 2893 |
gr.HTML(
|
| 2894 |
'<div class="step-heading"><span>Step 01</span><h2>Set up your generation</h2>'
|
| 2895 |
-
'<p>Paste the game
|
| 2896 |
)
|
| 2897 |
with gr.Row(equal_height=False):
|
| 2898 |
with gr.Column(scale=3, elem_classes=["studio-panel"]):
|
|
@@ -2901,17 +3218,18 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
|
|
| 2901 |
lines=22,
|
| 2902 |
placeholder="Paste your full HTML game code here.",
|
| 2903 |
value=STARTER_HTML,
|
| 2904 |
-
info="Use GAME_ASSETS.<
|
| 2905 |
)
|
| 2906 |
with gr.Column(scale=2, elem_classes=["studio-panel"]):
|
| 2907 |
roles = gr.Textbox(
|
| 2908 |
-
label="
|
| 2909 |
-
lines=
|
| 2910 |
placeholder=ROLE_PLACEHOLDER,
|
| 2911 |
value=DEFAULT_ROLES,
|
| 2912 |
info=(
|
| 2913 |
-
"
|
| 2914 |
-
"
|
|
|
|
| 2915 |
),
|
| 2916 |
)
|
| 2917 |
with gr.Row():
|
|
@@ -2953,14 +3271,14 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
|
|
| 2953 |
|
| 2954 |
gr.HTML(
|
| 2955 |
'<div class="step-heading"><span>Step 02</span><h2>Review generated assets</h2>'
|
| 2956 |
-
'<p>Check subject, perspective,
|
| 2957 |
)
|
| 2958 |
with gr.Row(equal_height=False):
|
| 2959 |
with gr.Column(scale=3, elem_classes=["studio-panel", "gallery-panel"]):
|
| 2960 |
gallery = gr.Gallery(label="Generated assets", columns=2, height=420)
|
| 2961 |
with gr.Column(scale=2, elem_classes=["studio-panel"]):
|
| 2962 |
selected_role = gr.Dropdown(
|
| 2963 |
-
label="
|
| 2964 |
choices=[],
|
| 2965 |
interactive=True,
|
| 2966 |
)
|
|
@@ -2997,7 +3315,7 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
|
|
| 2997 |
interactive=False,
|
| 2998 |
)
|
| 2999 |
model_report = gr.Textbox(
|
| 3000 |
-
label="Model/source used by
|
| 3001 |
lines=5,
|
| 3002 |
interactive=False,
|
| 3003 |
)
|
|
|
|
| 45 |
const ctx = canvas.getContext("2d");
|
| 46 |
|
| 47 |
const background = new Image();
|
| 48 |
+
background.src = "sprite_forest_clearing.png";
|
| 49 |
const playerImg = new Image();
|
| 50 |
+
playerImg.src = "sprite_forest_adventurer.png";
|
| 51 |
|
| 52 |
const keys = new Set();
|
| 53 |
const player = { x: 380, y: 205, w: 48, h: 48, speed: 4 };
|
|
|
|
| 83 |
</html>"""
|
| 84 |
|
| 85 |
|
| 86 |
+
DEFAULT_ROLES = """{
|
| 87 |
+
"assets": [
|
| 88 |
+
{
|
| 89 |
+
"id": "forest_adventurer",
|
| 90 |
+
"description": "top-down pixel-art adventurer with a bright readable silhouette",
|
| 91 |
+
"quantity": 1,
|
| 92 |
+
"width": 128,
|
| 93 |
+
"height": 128,
|
| 94 |
+
"transparent": true,
|
| 95 |
+
"composition": "single_subject",
|
| 96 |
+
"camera": "top-down"
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"id": "forest_clearing",
|
| 100 |
+
"description": "enchanted forest clearing with soft moonlight and readable traversal space",
|
| 101 |
+
"quantity": 1,
|
| 102 |
+
"width": 800,
|
| 103 |
+
"height": 450,
|
| 104 |
+
"transparent": false,
|
| 105 |
+
"composition": "full_frame",
|
| 106 |
+
"camera": "top-down"
|
| 107 |
+
}
|
| 108 |
+
]
|
| 109 |
+
}"""
|
| 110 |
+
|
| 111 |
+
ROLE_PLACEHOLDER = """{
|
| 112 |
+
"assets": [
|
| 113 |
+
{
|
| 114 |
+
"id": "asset_name_used_by_game_code",
|
| 115 |
+
"description": "Describe exactly what should be generated",
|
| 116 |
+
"quantity": 1,
|
| 117 |
+
"width": 256,
|
| 118 |
+
"height": 256,
|
| 119 |
+
"transparent": true,
|
| 120 |
+
"composition": "single_subject",
|
| 121 |
+
"camera": "top-down",
|
| 122 |
+
"variations": "Optional instructions that distinguish multiple outputs"
|
| 123 |
+
}
|
| 124 |
+
]
|
| 125 |
+
}"""
|
| 126 |
|
| 127 |
|
| 128 |
@dataclass
|
|
|
|
| 133 |
width: int
|
| 134 |
height: int
|
| 135 |
camera: str = "auto"
|
| 136 |
+
group: str = ""
|
| 137 |
+
variant_index: int = 1
|
| 138 |
+
total_variants: int = 1
|
| 139 |
+
transparent: bool = True
|
| 140 |
+
composition: str = "single_subject"
|
| 141 |
+
camera_instruction: str = "auto"
|
| 142 |
+
variation: str = ""
|
| 143 |
+
expected_subjects: int | None = 1
|
| 144 |
+
silhouette: str = "any"
|
| 145 |
|
| 146 |
|
| 147 |
@dataclass
|
|
|
|
| 378 |
return StylePlan(medium, palette, texture, lighting, linework, camera, tuple(tags))
|
| 379 |
|
| 380 |
|
| 381 |
+
SUPPORTED_COMPOSITIONS = {
|
| 382 |
+
"single_subject",
|
| 383 |
+
"full_frame",
|
| 384 |
+
"seamless",
|
| 385 |
+
"icon",
|
| 386 |
+
"animation_frame",
|
| 387 |
+
"sprite_sheet",
|
| 388 |
+
"freeform",
|
| 389 |
+
}
|
| 390 |
+
MAX_GENERATED_ASSETS = max(1, int(os.environ.get("MAX_GENERATED_ASSETS", "8")))
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
class AssetManifestError(ValueError):
|
| 394 |
+
pass
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def composition_instruction(composition: str, transparent: bool) -> str:
|
| 398 |
+
instructions = {
|
| 399 |
+
"single_subject": (
|
| 400 |
+
"Create exactly one complete standalone subject in one pose. Center it, keep it fully visible, and make "
|
| 401 |
+
"it fill about eighty percent of the frame with modest even margins."
|
| 402 |
+
),
|
| 403 |
+
"icon": "Create one centered readable icon with a strong silhouette and generous safe margins.",
|
| 404 |
+
"animation_frame": (
|
| 405 |
+
"Create one isolated animation frame with one complete subject, a stable scale, and consistent anchoring."
|
| 406 |
+
),
|
| 407 |
+
"full_frame": "Create one complete edge-to-edge image that fills the entire canvas.",
|
| 408 |
+
"seamless": "Create one edge-to-edge seamless tile whose opposite edges repeat cleanly.",
|
| 409 |
+
"sprite_sheet": (
|
| 410 |
+
"Create one deliberately organized sprite sheet on a regular grid. Keep every cell aligned, equally sized, "
|
| 411 |
+
"and free of labels."
|
| 412 |
+
),
|
| 413 |
+
"freeform": "Follow the requested composition exactly without adding labels or presentation mockups.",
|
| 414 |
+
}
|
| 415 |
+
alpha_instruction = (
|
| 416 |
+
"Use a clean uniform white studio field so the requested transparent cutout can be extracted."
|
| 417 |
+
if transparent
|
| 418 |
+
else "Use an opaque edge-to-edge presentation with no transparent holes."
|
| 419 |
+
)
|
| 420 |
+
return f"{instructions[composition]} {alpha_instruction}"
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def build_asset_prompt(
|
| 424 |
+
role: str,
|
| 425 |
+
prompt: str,
|
| 426 |
+
style_hint: str,
|
| 427 |
+
*,
|
| 428 |
+
transparent: bool | None = None,
|
| 429 |
+
composition: str | None = None,
|
| 430 |
+
camera_instruction: str = "",
|
| 431 |
+
variation: str = "",
|
| 432 |
+
) -> str:
|
| 433 |
plan = interpret_style_hint(style_hint)
|
| 434 |
+
if transparent is None or composition is None:
|
| 435 |
+
slug = slugify(role)
|
| 436 |
+
legacy_full_frame = any(word in slug for word in ("background", "backdrop", "scene", "map", "level"))
|
| 437 |
+
transparent = not legacy_full_frame if transparent is None else transparent
|
| 438 |
+
composition = ("full_frame" if legacy_full_frame else "single_subject") if composition is None else composition
|
| 439 |
+
camera_text = camera_instruction or f"{style_hint} {prompt}"
|
| 440 |
+
camera = normalize_camera(camera_text)
|
| 441 |
+
full_frame = composition in {"full_frame", "seamless", "sprite_sheet"} or not transparent
|
| 442 |
+
camera_contract = camera_prompt_contract(camera, full_frame)
|
| 443 |
+
if camera == "auto" and camera_instruction and camera_instruction.lower() != "auto":
|
| 444 |
+
camera_contract = f"Use this exact user-defined camera instruction: {camera_instruction}."
|
| 445 |
+
variation_instruction = f"Variation requirement: {variation}." if variation else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
return (
|
| 447 |
f"{role} asset: {prompt}. Creative brief: {style_hint}. "
|
| 448 |
f"Style interpretation: {plan.medium}; {plan.palette}; "
|
| 449 |
f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. "
|
| 450 |
+
f"Camera contract: {camera_contract} "
|
| 451 |
+
f"Output contract: {composition_instruction(composition, transparent)} "
|
| 452 |
+
f"{variation_instruction} "
|
| 453 |
"Game asset, readable at small size, with a clean unlabeled presentation."
|
| 454 |
)
|
| 455 |
|
|
|
|
| 466 |
return ". ".join(parts) or "cohesive game-ready 2D art"
|
| 467 |
|
| 468 |
|
| 469 |
+
def _coerce_manifest_bool(value, field: str) -> bool:
|
| 470 |
+
if isinstance(value, bool):
|
| 471 |
+
return value
|
| 472 |
+
if isinstance(value, str) and value.strip().lower() in {"true", "yes", "1"}:
|
| 473 |
+
return True
|
| 474 |
+
if isinstance(value, str) and value.strip().lower() in {"false", "no", "0"}:
|
| 475 |
+
return False
|
| 476 |
+
raise AssetManifestError(f"`{field}` must be true or false.")
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def parse_asset_entries(raw_roles: str) -> tuple[list[dict], bool]:
|
| 480 |
+
"""Parse the recommended JSON manifest or the backwards-compatible role syntax."""
|
| 481 |
+
raw = (raw_roles or "").strip()
|
| 482 |
+
if not raw:
|
| 483 |
+
return [], False
|
| 484 |
+
if not raw.startswith(("{", "[")):
|
| 485 |
+
entries = []
|
| 486 |
+
for line in raw.splitlines():
|
| 487 |
+
line = line.strip()
|
| 488 |
+
if not line or line.startswith("#"):
|
| 489 |
+
continue
|
| 490 |
+
if ":" in line:
|
| 491 |
+
role, prompt = line.split(":", 1)
|
| 492 |
+
elif "=" in line:
|
| 493 |
+
role, prompt = line.split("=", 1)
|
| 494 |
+
else:
|
| 495 |
+
role, prompt = line, line
|
| 496 |
+
role = role.strip()
|
| 497 |
+
entries.append({"id": role, "description": prompt.strip() or role, "legacy": True})
|
| 498 |
+
return entries, False
|
| 499 |
+
|
| 500 |
+
try:
|
| 501 |
+
document = json.loads(raw)
|
| 502 |
+
except json.JSONDecodeError as exc:
|
| 503 |
+
raise AssetManifestError(f"Asset manifest is not valid JSON: line {exc.lineno}, column {exc.colno}.") from exc
|
| 504 |
+
items = document.get("assets") if isinstance(document, dict) else document
|
| 505 |
+
if not isinstance(items, list):
|
| 506 |
+
raise AssetManifestError("Asset manifest must be a JSON array or an object containing an `assets` array.")
|
| 507 |
+
entries = []
|
| 508 |
+
for index, item in enumerate(items, 1):
|
| 509 |
+
if not isinstance(item, dict):
|
| 510 |
+
raise AssetManifestError(f"Asset {index} must be a JSON object.")
|
| 511 |
+
asset_id = str(item.get("id") or item.get("name") or item.get("role") or "").strip()
|
| 512 |
+
description = str(item.get("description") or item.get("prompt") or "").strip()
|
| 513 |
+
if not asset_id:
|
| 514 |
+
raise AssetManifestError(f"Asset {index} is missing a non-empty `id`.")
|
| 515 |
+
if not description:
|
| 516 |
+
raise AssetManifestError(f"Asset `{asset_id}` is missing a non-empty `description`.")
|
| 517 |
+
entries.append({**item, "id": asset_id, "description": description, "legacy": False})
|
| 518 |
+
return entries, True
|
| 519 |
+
|
| 520 |
+
|
| 521 |
def parse_role_lines(raw_roles: str) -> list[tuple[str, str]]:
|
| 522 |
+
entries, _ = parse_asset_entries(raw_roles)
|
| 523 |
parsed: list[tuple[str, str]] = []
|
| 524 |
+
for entry in entries:
|
| 525 |
+
parsed.append((entry["id"], entry["description"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
return parsed
|
| 527 |
|
| 528 |
|
|
|
|
| 594 |
|
| 595 |
|
| 596 |
def local_prompt_map(role_lines: list[tuple[str, str]], style_hint: str) -> dict[str, str]:
|
| 597 |
+
del style_hint
|
| 598 |
+
return {role: prompt for role, prompt in role_lines}
|
| 599 |
|
| 600 |
|
| 601 |
def extract_json_object(text: str) -> dict | None:
|
|
|
|
| 627 |
role_block = "\n".join(f"- {role}: {prompt}" for role, prompt in role_lines)
|
| 628 |
instruction = (
|
| 629 |
"You are a senior game art director and prompt engineer. Read the HTML game context, "
|
| 630 |
+
"the requested user-defined assets, and the shared theme/style. Return ONLY a JSON object where "
|
| 631 |
"each key is the exact role name and each value is one concise text-to-image prompt. "
|
| 632 |
+
"Preserve the user's requested subject and make different assets visually distinct. Do not guess an asset "
|
| 633 |
+
"category from its name. The application will append the exact camera, composition, dimensions, alpha, and "
|
| 634 |
+
"quantity contracts after your response. Never combine different requested assets into one image."
|
|
|
|
| 635 |
)
|
| 636 |
user_text = (
|
| 637 |
f"HTML/game context summary: {infer_code_context(html_code)}\n\n"
|
|
|
|
| 703 |
prompt_map: dict[str, str] | None = None,
|
| 704 |
role_lines: list[tuple[str, str]] | None = None,
|
| 705 |
) -> list[AssetSpec]:
|
| 706 |
+
entries, explicit_manifest = parse_asset_entries(raw_roles)
|
| 707 |
+
entries_by_slug = {slugify(entry["id"]): entry for entry in entries}
|
| 708 |
specs: list[AssetSpec] = []
|
| 709 |
for role, prompt in role_lines if role_lines is not None else parse_role_lines(raw_roles):
|
| 710 |
+
group = slugify(role)
|
| 711 |
+
entry = entries_by_slug.get(group)
|
| 712 |
+
if entry is None:
|
| 713 |
+
entry = {"id": role, "description": prompt, "legacy": True}
|
| 714 |
+
legacy = bool(entry.get("legacy"))
|
| 715 |
+
if legacy:
|
| 716 |
+
legacy_full_frame = any(
|
| 717 |
+
word in group for word in ("background", "backdrop", "scene", "map", "level")
|
| 718 |
+
)
|
| 719 |
+
transparent = not legacy_full_frame
|
| 720 |
+
composition = "full_frame" if legacy_full_frame else "single_subject"
|
| 721 |
+
width, height = (800, 450) if legacy_full_frame else (128, 128)
|
| 722 |
+
expected_subjects = None if legacy_full_frame else 1
|
| 723 |
+
silhouette = (
|
| 724 |
+
"humanoid"
|
| 725 |
+
if contains_any_term(
|
| 726 |
+
f"{role} {prompt}",
|
| 727 |
+
("player", "hero", "enemy", "boss", "npc", "character", "ranger", "knight", "adventurer"),
|
| 728 |
)
|
| 729 |
+
else "any"
|
| 730 |
+
)
|
| 731 |
else:
|
| 732 |
+
transparent = _coerce_manifest_bool(entry.get("transparent", True), f"{group}.transparent")
|
| 733 |
+
composition = str(entry.get("composition") or ("single_subject" if transparent else "full_frame"))
|
| 734 |
+
composition = slugify(composition)
|
| 735 |
+
if composition not in SUPPORTED_COMPOSITIONS:
|
| 736 |
+
allowed = ", ".join(sorted(SUPPORTED_COMPOSITIONS))
|
| 737 |
+
raise AssetManifestError(
|
| 738 |
+
f"Asset `{group}` has unsupported composition `{composition}`. Choose one of: {allowed}."
|
| 739 |
+
)
|
| 740 |
+
default_size = (800, 450) if composition == "full_frame" else (256, 256)
|
| 741 |
+
try:
|
| 742 |
+
width = int(entry.get("width", default_size[0]))
|
| 743 |
+
height = int(entry.get("height", default_size[1]))
|
| 744 |
+
except (TypeError, ValueError) as exc:
|
| 745 |
+
raise AssetManifestError(f"Asset `{group}` width and height must be integers.") from exc
|
| 746 |
+
if not (32 <= width <= 2048 and 32 <= height <= 2048):
|
| 747 |
+
raise AssetManifestError(f"Asset `{group}` dimensions must each be between 32 and 2048 pixels.")
|
| 748 |
+
expected_value = entry.get("expected_subjects")
|
| 749 |
+
if expected_value is None:
|
| 750 |
+
expected_subjects = 1 if composition in {"single_subject", "icon", "animation_frame"} else None
|
| 751 |
+
else:
|
| 752 |
+
try:
|
| 753 |
+
expected_subjects = int(expected_value)
|
| 754 |
+
except (TypeError, ValueError) as exc:
|
| 755 |
+
raise AssetManifestError(f"Asset `{group}` expected_subjects must be an integer.") from exc
|
| 756 |
+
if not (1 <= expected_subjects <= 16):
|
| 757 |
+
raise AssetManifestError(f"Asset `{group}` expected_subjects must be between 1 and 16.")
|
| 758 |
+
silhouette = slugify(str(entry.get("silhouette") or "any"))
|
| 759 |
+
if silhouette not in {"any", "humanoid"}:
|
| 760 |
+
raise AssetManifestError(f"Asset `{group}` silhouette must be `any` or `humanoid`.")
|
| 761 |
+
try:
|
| 762 |
+
quantity = int(entry.get("quantity", 1))
|
| 763 |
+
except (TypeError, ValueError) as exc:
|
| 764 |
+
raise AssetManifestError(f"Asset `{group}` quantity must be an integer.") from exc
|
| 765 |
+
if not (1 <= quantity <= MAX_GENERATED_ASSETS):
|
| 766 |
+
raise AssetManifestError(
|
| 767 |
+
f"Asset `{group}` quantity must be between 1 and {MAX_GENERATED_ASSETS}; use multiple batches for larger sets."
|
| 768 |
+
)
|
| 769 |
+
camera_instruction = str(entry.get("camera") or "").strip()
|
| 770 |
+
if not camera_instruction or camera_instruction.lower() in {"inherit", "shared", "auto"}:
|
| 771 |
+
camera_instruction = style_hint
|
| 772 |
+
camera = normalize_camera(camera_instruction)
|
| 773 |
+
variation = str(entry.get("variations") or entry.get("variation") or "").strip()
|
| 774 |
+
base_filename = str(entry.get("filename") or f"sprite_{group}.png").strip()
|
| 775 |
+
if not re.fullmatch(r"[a-zA-Z0-9_.-]+\.png", base_filename, flags=re.I):
|
| 776 |
+
raise AssetManifestError(
|
| 777 |
+
f"Asset `{group}` filename must be a simple PNG filename without folders."
|
| 778 |
)
|
| 779 |
+
interpreted_prompt = (prompt_map or {}).get(role) or prompt
|
| 780 |
+
for variant_index in range(1, quantity + 1):
|
| 781 |
+
output_role = group if quantity == 1 else f"{group}_{variant_index:02d}"
|
| 782 |
+
stem, extension = base_filename.rsplit(".", 1)
|
| 783 |
+
filename = base_filename if quantity == 1 else f"{stem}_{variant_index:02d}.{extension}"
|
| 784 |
+
variant_direction = variation
|
| 785 |
+
if quantity > 1:
|
| 786 |
+
variant_direction = (
|
| 787 |
+
f"Output {variant_index} of {quantity}. Make it recognizably different from the other outputs"
|
| 788 |
+
+ (f"; {variation}" if variation else ".")
|
| 789 |
+
)
|
| 790 |
+
full_prompt = build_asset_prompt(
|
| 791 |
+
output_role,
|
| 792 |
+
interpreted_prompt,
|
| 793 |
+
style_hint,
|
| 794 |
+
transparent=transparent,
|
| 795 |
+
composition=composition,
|
| 796 |
+
camera_instruction=camera_instruction,
|
| 797 |
+
variation=variant_direction,
|
| 798 |
+
)
|
| 799 |
+
specs.append(
|
| 800 |
+
AssetSpec(
|
| 801 |
+
role=output_role,
|
| 802 |
+
prompt=full_prompt,
|
| 803 |
+
filename=filename,
|
| 804 |
+
width=width,
|
| 805 |
+
height=height,
|
| 806 |
+
camera=camera,
|
| 807 |
+
group=group,
|
| 808 |
+
variant_index=variant_index,
|
| 809 |
+
total_variants=quantity,
|
| 810 |
+
transparent=transparent,
|
| 811 |
+
composition=composition,
|
| 812 |
+
camera_instruction=camera_instruction,
|
| 813 |
+
variation=variant_direction,
|
| 814 |
+
expected_subjects=expected_subjects,
|
| 815 |
+
silhouette=silhouette,
|
| 816 |
+
)
|
| 817 |
+
)
|
| 818 |
+
if len(specs) > MAX_GENERATED_ASSETS:
|
| 819 |
+
raise AssetManifestError(
|
| 820 |
+
f"This request expands to {len(specs)} images. The free ZeroGPU batch limit is {MAX_GENERATED_ASSETS}; "
|
| 821 |
+
"reduce quantities and generate the remainder in another batch."
|
| 822 |
)
|
| 823 |
return specs
|
| 824 |
|
| 825 |
|
| 826 |
def is_background_spec(spec: AssetSpec) -> bool:
|
| 827 |
+
"""Compatibility name for assets that should not use single-cutout post-processing."""
|
| 828 |
+
return not (
|
| 829 |
+
spec.transparent
|
| 830 |
+
and spec.composition in {"single_subject", "icon", "animation_frame"}
|
| 831 |
)
|
| 832 |
|
| 833 |
|
|
|
|
| 1842 |
|
| 1843 |
|
| 1844 |
def diffusion_negative_prompt(spec: AssetSpec) -> str:
|
| 1845 |
+
camera_negative = {
|
| 1846 |
+
"top_down": ", sky, horizon, eye-level view, front view, front-facing facade, vanishing point",
|
| 1847 |
+
"isometric": ", eye-level view, front view, inconsistent perspective, horizon",
|
| 1848 |
+
"side_view": ", overhead view, top-down view, isometric view",
|
| 1849 |
+
}.get(spec.camera, "")
|
| 1850 |
+
if spec.composition in {"single_subject", "icon", "animation_frame"}:
|
| 1851 |
return (
|
| 1852 |
+
"multiple subjects, duplicate character, duplicate subject, repeated subject, character sheet, model sheet, turnaround, "
|
| 1853 |
+
"lineup, alternate views, multiple poses, multiple views, pair, group, cropped subject, scenery, landscape, "
|
| 1854 |
+
"room, ground, floor, background props, drop shadow, text, watermark"
|
| 1855 |
+ camera_negative
|
| 1856 |
)
|
| 1857 |
+
if spec.composition == "seamless":
|
| 1858 |
+
return "visible seams, borders, frame, perspective mockup, text, watermark" + camera_negative
|
| 1859 |
+
if spec.composition == "sprite_sheet":
|
| 1860 |
+
return "irregular grid, overlapping cells, labels, text, watermark" + camera_negative
|
| 1861 |
+
return "text, labels, watermark, presentation mockup" + camera_negative
|
| 1862 |
|
| 1863 |
|
| 1864 |
def build_camera_control_image(spec: AssetSpec, width: int = 1024, height: int = 576) -> Image.Image:
|
|
|
|
| 1950 |
|
| 1951 |
def primary_diffusion_prompt(spec: AssetSpec) -> str:
|
| 1952 |
camera = primary_camera_phrase(spec.camera, is_background_spec(spec))
|
| 1953 |
+
if spec.camera == "auto" and spec.camera_instruction and spec.camera_instruction.lower() != "auto":
|
| 1954 |
+
camera = f"User-defined camera: {compact_prompt_words(spec.camera_instruction, 10)}."
|
| 1955 |
+
variation = f"Distinct variation: {compact_prompt_words(spec.variation, 8)}. " if spec.variation else ""
|
| 1956 |
+
if spec.composition == "single_subject":
|
| 1957 |
+
description = compact_prompt_words(spec.prompt, 12 if variation else 18)
|
| 1958 |
+
subject = (spec.group or spec.role).replace("_", " ")
|
| 1959 |
return (
|
| 1960 |
+
f"{camera} Single isolated {subject} subject. One complete body, one pose, centered and fully visible. "
|
| 1961 |
+
f"Fill about eighty percent of the frame with modest even margins on a uniform white field. "
|
| 1962 |
+
f"{variation}{description}"
|
| 1963 |
)
|
| 1964 |
+
description = compact_prompt_words(spec.prompt, 26 if variation else 34)
|
| 1965 |
+
composition = {
|
| 1966 |
+
"icon": "Exactly one centered readable game icon, uniform white field.",
|
| 1967 |
+
"animation_frame": "Exactly one isolated animation frame subject at stable scale, uniform white field.",
|
| 1968 |
+
"full_frame": "One complete edge-to-edge game image filling the canvas.",
|
| 1969 |
+
"seamless": "One seamless edge-to-edge repeating game-art tile.",
|
| 1970 |
+
"sprite_sheet": "One deliberately organized regular-grid sprite sheet.",
|
| 1971 |
+
"freeform": "One user-defined game image following the requested composition exactly.",
|
| 1972 |
+
}[spec.composition]
|
| 1973 |
+
return f"{camera} {composition} {variation}{description}"
|
| 1974 |
+
|
| 1975 |
+
|
| 1976 |
+
def primary_generation_dimensions(spec: AssetSpec) -> tuple[int, int]:
|
| 1977 |
+
if not is_background_spec(spec):
|
| 1978 |
+
return 768, 1024
|
| 1979 |
+
aspect = spec.width / max(1, spec.height)
|
| 1980 |
+
if aspect >= 1.35:
|
| 1981 |
+
return 1344, 768
|
| 1982 |
+
if aspect <= 0.74:
|
| 1983 |
+
return 768, 1024
|
| 1984 |
+
return 1024, 1024
|
| 1985 |
|
| 1986 |
|
| 1987 |
def primary_diffusion_png(spec: AssetSpec, index: int, run_id: int) -> tuple[bytes | None, str | None]:
|
|
|
|
| 1998 |
|
| 1999 |
is_background = is_background_spec(spec)
|
| 2000 |
attempts = 1 if is_background else PRIMARY_SPRITE_ATTEMPTS
|
| 2001 |
+
width, height = primary_generation_dimensions(spec)
|
| 2002 |
last_failure_detail = "the last output did not contain one valid foreground subject"
|
| 2003 |
for attempt in range(attempts):
|
| 2004 |
seed = abs(hash(f"primary|{spec.role}|{spec.prompt}|{index}|{run_id}|{attempt}")) % 2147483647
|
|
|
|
| 2093 |
|
| 2094 |
def polish_diffusion_asset(image: Image.Image, spec: AssetSpec) -> bytes:
|
| 2095 |
image = image.convert("RGBA")
|
| 2096 |
+
if spec.transparent:
|
| 2097 |
# Text-to-image models do not produce real transparency. This makes sprites
|
| 2098 |
+
# and other requested cutouts usable by removing corner-matched color.
|
|
|
|
| 2099 |
corner_source = image.resize((64, 64), Image.LANCZOS)
|
| 2100 |
source_corners = [
|
| 2101 |
corner_source.getpixel((0, 0)),
|
|
|
|
| 2105 |
]
|
| 2106 |
source_bg = tuple(sum(pixel[i] for pixel in source_corners) // len(source_corners) for i in range(3))
|
| 2107 |
contained = image.copy()
|
| 2108 |
+
contained.thumbnail((spec.width, spec.height), Image.LANCZOS)
|
| 2109 |
+
small = Image.new("RGBA", (spec.width, spec.height), (*source_bg, 255))
|
| 2110 |
+
offset = ((spec.width - contained.width) // 2, (spec.height - contained.height) // 2)
|
| 2111 |
small.alpha_composite(contained, dest=offset)
|
| 2112 |
corners = [
|
| 2113 |
small.getpixel((0, 0)),
|
| 2114 |
+
small.getpixel((small.width - 1, 0)),
|
| 2115 |
+
small.getpixel((0, small.height - 1)),
|
| 2116 |
+
small.getpixel((small.width - 1, small.height - 1)),
|
| 2117 |
]
|
| 2118 |
bg = tuple(sum(pixel[i] for pixel in corners) // len(corners) for i in range(3))
|
| 2119 |
pixels = small.load()
|
|
|
|
| 2127 |
elif dist < 125:
|
| 2128 |
a = max(0, min(a, (dist - 64) * 4))
|
| 2129 |
pixels[x, y] = (r, g, b, a)
|
| 2130 |
+
image = (
|
| 2131 |
+
normalize_sprite_foreground(small, spec)
|
| 2132 |
+
if spec.composition in {"single_subject", "icon", "animation_frame"}
|
| 2133 |
+
else small
|
| 2134 |
+
)
|
| 2135 |
else:
|
| 2136 |
image = image.resize((spec.width, spec.height), Image.LANCZOS)
|
| 2137 |
out = io.BytesIO()
|
|
|
|
| 2311 |
|
| 2312 |
def has_implausibly_thin_foreground_subject(content: bytes, spec: AssetSpec) -> bool:
|
| 2313 |
"""Reject collapsed humanoid silhouettes without penalizing projectiles or narrow props."""
|
| 2314 |
+
if spec.silhouette != "humanoid":
|
| 2315 |
return False
|
| 2316 |
components = foreground_component_geometry(content)
|
| 2317 |
if not components:
|
|
|
|
| 2521 |
|
| 2522 |
def code_references_role(html_code: str, spec: AssetSpec) -> bool:
|
| 2523 |
slug = slugify(spec.role)
|
| 2524 |
+
group = slugify(spec.group or spec.role)
|
| 2525 |
+
hook_names = {slug, group}
|
| 2526 |
hook_patterns = (
|
| 2527 |
+
pattern
|
| 2528 |
+
for hook_name in hook_names
|
| 2529 |
+
for pattern in (
|
| 2530 |
+
rf"(?:window\.)?GAME_ASSETS\s*\.\s*{re.escape(hook_name)}\b",
|
| 2531 |
+
rf"(?:window\.)?GAME_ASSETS\s*\[\s*['\"]{re.escape(hook_name)}['\"]\s*\]",
|
| 2532 |
+
)
|
| 2533 |
)
|
| 2534 |
if any(re.search(pattern, html_code, flags=re.I) for pattern in hook_patterns):
|
| 2535 |
return True
|
|
|
|
| 2573 |
warnings.append(f"unexpected dimensions {image.width}x{image.height}")
|
| 2574 |
alpha = image.getchannel("A")
|
| 2575 |
extrema = alpha.getextrema()
|
| 2576 |
+
if not spec.transparent:
|
| 2577 |
if extrema[0] < 255:
|
| 2578 |
+
warnings.append("opaque output contains transparency")
|
| 2579 |
else:
|
| 2580 |
transparent_pixels = sum(alpha.histogram()[:16])
|
| 2581 |
transparent_ratio = transparent_pixels / max(1, image.width * image.height)
|
| 2582 |
if transparent_ratio < 0.12:
|
| 2583 |
+
warnings.append("transparent output does not contain enough transparent area")
|
| 2584 |
corners = (
|
| 2585 |
alpha.getpixel((0, 0)),
|
| 2586 |
alpha.getpixel((image.width - 1, 0)),
|
|
|
|
| 2588 |
alpha.getpixel((image.width - 1, image.height - 1)),
|
| 2589 |
)
|
| 2590 |
if any(value > 16 for value in corners):
|
| 2591 |
+
warnings.append("transparent output has opaque corner pixels")
|
| 2592 |
+
if spec.expected_subjects is not None:
|
| 2593 |
+
subject_count = estimated_foreground_subject_count(content)
|
| 2594 |
+
if subject_count != spec.expected_subjects:
|
| 2595 |
+
warnings.append(
|
| 2596 |
+
f"expected {spec.expected_subjects} significant foreground subject(s), found {subject_count}"
|
| 2597 |
+
)
|
| 2598 |
+
elif spec.expected_subjects == 1 and has_implausibly_thin_foreground_subject(content, spec):
|
| 2599 |
+
warnings.append("sprite foreground silhouette is implausibly thin")
|
| 2600 |
+
elif spec.expected_subjects == 1 and has_undersized_foreground_subject(content):
|
| 2601 |
+
warnings.append("foreground subject occupies too little of the output canvas")
|
| 2602 |
+
if spec.composition == "seamless" and image.width > 1 and image.height > 1:
|
| 2603 |
+
pixels = image.convert("RGB")
|
| 2604 |
+
horizontal_error = sum(
|
| 2605 |
+
sum(abs(a - b) for a, b in zip(pixels.getpixel((0, y)), pixels.getpixel((image.width - 1, y))))
|
| 2606 |
+
for y in range(image.height)
|
| 2607 |
+
) / (image.height * 3 * 255)
|
| 2608 |
+
vertical_error = sum(
|
| 2609 |
+
sum(abs(a - b) for a, b in zip(pixels.getpixel((x, 0)), pixels.getpixel((x, image.height - 1))))
|
| 2610 |
+
for x in range(image.width)
|
| 2611 |
+
) / (image.width * 3 * 255)
|
| 2612 |
+
if max(horizontal_error, vertical_error) > 0.16:
|
| 2613 |
+
warnings.append("opposite edges differ too much for a reliable seamless tile")
|
| 2614 |
return warnings
|
| 2615 |
|
| 2616 |
|
|
|
|
| 2621 |
|
| 2622 |
output = html_code
|
| 2623 |
manifest_lines = ["<!-- Embedded game assets generated by Image Generator for HTML Games"]
|
| 2624 |
+
asset_map: dict[str, str | list[str]] = {}
|
| 2625 |
+
grouped_assets: dict[str, list[tuple[int, str]]] = {}
|
| 2626 |
|
| 2627 |
for spec in specs:
|
| 2628 |
data_uri = assets[spec.role]
|
| 2629 |
slug = slugify(spec.role)
|
| 2630 |
+
group = slugify(spec.group or spec.role)
|
| 2631 |
+
grouped_assets.setdefault(group, []).append((spec.variant_index, data_uri))
|
| 2632 |
+
if spec.total_variants > 1:
|
| 2633 |
+
asset_map[slug] = data_uri
|
| 2634 |
manifest_lines.append(f"{spec.role}: {spec.filename}")
|
| 2635 |
for name in replacement_names(spec):
|
| 2636 |
output = output.replace(f'"{name}"', f'"{data_uri}"')
|
|
|
|
| 2638 |
if name.startswith("{"):
|
| 2639 |
output = output.replace(name, data_uri)
|
| 2640 |
|
| 2641 |
+
for group, variants in grouped_assets.items():
|
| 2642 |
+
ordered = [data_uri for _, data_uri in sorted(variants)]
|
| 2643 |
+
asset_map[group] = ordered if len(ordered) > 1 else ordered[0]
|
| 2644 |
+
|
| 2645 |
manifest_lines.append("-->")
|
| 2646 |
manifest = "\n".join(manifest_lines) + "\n"
|
| 2647 |
asset_json = json.dumps(asset_map)
|
| 2648 |
helper_script = f"""<script>
|
| 2649 |
(function () {{
|
| 2650 |
var ASSETS = {asset_json};
|
| 2651 |
+
Object.keys(ASSETS).forEach(function (key) {{
|
| 2652 |
+
if (Array.isArray(ASSETS[key])) Object.freeze(ASSETS[key]);
|
| 2653 |
+
}});
|
| 2654 |
window.GAME_ASSETS = Object.freeze(Object.assign({{}}, window.GAME_ASSETS || {{}}, ASSETS));
|
| 2655 |
window.GENERATED_GAME_ASSETS = ASSETS;
|
| 2656 |
}})();
|
|
|
|
| 2756 |
readiness = "loads in the deployed ZeroGPU runtime"
|
| 2757 |
return (
|
| 2758 |
"**Configured model pipeline:** "
|
| 2759 |
+
f"prompts: `{prompt_source}` · transparent single-subject contracts: `{sprite_source}` · "
|
| 2760 |
+
f"full-frame/freeform contracts: `{background_source}` · "
|
| 2761 |
f"remote image fallback: `{remote_fallback}` · neural image models: `{neural_status}` · "
|
| 2762 |
f"neural enforcement: `{enforcement}` · primary readiness: `{readiness}`. "
|
| 2763 |
"Every production image is generated directly from its written prompt by the primary text-to-image model. "
|
| 2764 |
+
"The user-defined composition and camera contract is placed first inside a conservative CLIP prompt budget. "
|
| 2765 |
+
"Transparent single-subject foregrounds are normalized to the requested canvas, then pass configured subject-count, "
|
| 2766 |
"silhouette, and scale validation; failures are regenerated with a new seed up to "
|
| 2767 |
f"{PRIMARY_SPRITE_ATTEMPTS} times. No procedural guide is supplied to the model. The procedural renderer is "
|
| 2768 |
"development-only and is blocked in the deployed Space when the primary model fails."
|
|
|
|
| 2793 |
(spec.role, state["prompt_model"], state["image_models"][spec.role])
|
| 2794 |
for spec in specs
|
| 2795 |
]
|
| 2796 |
+
asset_set_count = len({spec.group or spec.role for spec in specs})
|
| 2797 |
if integration.supported:
|
| 2798 |
rewritten = embed_assets(html_code, assets, specs)
|
| 2799 |
preview_html = build_preview(rewritten)
|
| 2800 |
status = (
|
| 2801 |
+
f"{action} {len(specs)} output(s) across {asset_set_count} custom asset set(s) and embedded them through the deterministic "
|
| 2802 |
f"GAME_ASSETS contract using {summarize_model_sources(model_rows)}."
|
| 2803 |
)
|
| 2804 |
else:
|
| 2805 |
rewritten = ""
|
| 2806 |
preview_html = build_preview(html_code)
|
| 2807 |
status = (
|
| 2808 |
+
f"{action} {len(specs)} output(s) across {asset_set_count} custom asset set(s), but did not rewrite the game because deterministic "
|
| 2809 |
"asset hooks are missing. The preview below is the unchanged original game."
|
| 2810 |
)
|
| 2811 |
if integration.warnings:
|
|
|
|
| 2813 |
inferred_roles = state.get("inferred_roles", [])
|
| 2814 |
if inferred_roles:
|
| 2815 |
status += (
|
| 2816 |
+
"\n\n- Added backward-compatible asset specifications from deterministic game-code hooks: "
|
| 2817 |
+ ", ".join(inferred_roles)
|
| 2818 |
+ ". Add explicit descriptions for these roles to improve art direction."
|
| 2819 |
)
|
|
|
|
| 2857 |
theme: str,
|
| 2858 |
) -> int:
|
| 2859 |
"""Reserve realistic ZeroGPU time based on the number of independent assets."""
|
| 2860 |
+
try:
|
| 2861 |
+
style_context = build_style_context(game_type, perspective, theme)
|
| 2862 |
+
role_lines, _ = resolve_role_lines(html_code or "", roles or "")
|
| 2863 |
+
output_count = max(1, len(parse_assets(roles or "", style_context, role_lines=role_lines)))
|
| 2864 |
+
except AssetManifestError:
|
| 2865 |
+
try:
|
| 2866 |
+
output_count = max(1, len(resolve_role_lines(html_code or "", roles or "")[0]))
|
| 2867 |
+
except AssetManifestError:
|
| 2868 |
+
output_count = 1
|
| 2869 |
+
return min(120, 30 + output_count * 18)
|
| 2870 |
|
| 2871 |
|
| 2872 |
@gpu_task(duration=estimate_generation_gpu_duration)
|
|
|
|
| 2881 |
return empty_generation_result("Paste HTML game code first.")
|
| 2882 |
|
| 2883 |
style_context = build_style_context(game_type, perspective, theme)
|
| 2884 |
+
try:
|
| 2885 |
+
role_lines, prompt_map, prompt_model, prompt_error, inferred_roles = build_prompt_map(
|
| 2886 |
+
html_code,
|
| 2887 |
+
roles,
|
| 2888 |
+
style_context,
|
| 2889 |
+
)
|
| 2890 |
+
specs = parse_assets(roles, style_context, prompt_map, role_lines)
|
| 2891 |
+
except AssetManifestError as exc:
|
| 2892 |
+
return empty_generation_result(str(exc), html_code)
|
| 2893 |
if not specs:
|
| 2894 |
return empty_generation_result(
|
| 2895 |
+
"Add at least one asset specification to the manifest.",
|
| 2896 |
html_code,
|
| 2897 |
)
|
| 2898 |
slugs = [slugify(spec.role) for spec in specs]
|
|
|
|
| 2936 |
"run_id": run_id,
|
| 2937 |
}
|
| 2938 |
rendered = render_generation_state(state, "Generated", errors)
|
| 2939 |
+
role_choices = regeneration_choices(specs)
|
| 2940 |
+
approval_choices = [spec.role for spec in specs]
|
| 2941 |
return (
|
| 2942 |
*rendered,
|
| 2943 |
state,
|
| 2944 |
+
gr.Dropdown(choices=role_choices, value=specs[0].role),
|
| 2945 |
+
gr.CheckboxGroup(choices=approval_choices, value=[]),
|
| 2946 |
)
|
| 2947 |
|
| 2948 |
|
| 2949 |
+
def regeneration_choices(specs: list[AssetSpec]) -> list:
|
| 2950 |
+
choices: list = [spec.role for spec in specs]
|
| 2951 |
+
groups: dict[str, int] = {}
|
| 2952 |
+
for spec in specs:
|
| 2953 |
+
group = spec.group or spec.role
|
| 2954 |
+
groups[group] = groups.get(group, 0) + 1
|
| 2955 |
+
choices.extend((f"All outputs: {group}", f"group::{group}") for group, count in groups.items() if count > 1)
|
| 2956 |
+
return choices
|
| 2957 |
+
|
| 2958 |
+
|
| 2959 |
+
def estimate_regeneration_gpu_duration(state: dict, selected_role: str, approved_roles: list[str]) -> int:
|
| 2960 |
+
del approved_roles
|
| 2961 |
+
specs = (state or {}).get("specs", [])
|
| 2962 |
+
if (selected_role or "").startswith("group::"):
|
| 2963 |
+
group = selected_role.split("::", 1)[1]
|
| 2964 |
+
count = sum(1 for spec in specs if (spec.group or spec.role) == group)
|
| 2965 |
+
else:
|
| 2966 |
+
count = 1
|
| 2967 |
+
return min(120, 27 + max(1, count) * 18)
|
| 2968 |
+
|
| 2969 |
+
|
| 2970 |
+
@gpu_task(duration=estimate_regeneration_gpu_duration)
|
| 2971 |
def regenerate_selected_asset(state: dict, selected_role: str, approved_roles: list[str]):
|
| 2972 |
if not state or not selected_role:
|
| 2973 |
return (
|
|
|
|
| 2982 |
gr.CheckboxGroup(choices=[], value=[]),
|
| 2983 |
)
|
| 2984 |
specs = state["specs"]
|
| 2985 |
+
if selected_role.startswith("group::"):
|
| 2986 |
+
selected_group = selected_role.split("::", 1)[1]
|
| 2987 |
+
indices = [i for i, spec in enumerate(specs) if (spec.group or spec.role) == selected_group]
|
| 2988 |
+
else:
|
| 2989 |
+
indices = [i for i, spec in enumerate(specs) if spec.role == selected_role]
|
| 2990 |
+
if not indices:
|
| 2991 |
rendered = render_generation_state(state, "Kept")
|
| 2992 |
roles = [spec.role for spec in specs]
|
| 2993 |
return (*rendered, state, gr.CheckboxGroup(choices=roles, value=approved_roles or []))
|
| 2994 |
|
|
|
|
| 2995 |
run_id = time.time_ns()
|
| 2996 |
+
errors = []
|
| 2997 |
+
regenerated_roles = []
|
| 2998 |
+
for index in indices:
|
| 2999 |
+
spec = specs[index]
|
| 3000 |
+
try:
|
| 3001 |
+
data_uri, gallery_path, error, image_model = generate_asset(spec, index, run_id)
|
| 3002 |
+
except RuntimeError as exc:
|
| 3003 |
+
rendered = render_generation_state(state, "Kept", [str(exc)])
|
| 3004 |
+
roles = [item.role for item in specs]
|
| 3005 |
+
return (*rendered, state, gr.CheckboxGroup(choices=roles, value=approved_roles or []))
|
| 3006 |
+
state["assets"][spec.role] = data_uri
|
| 3007 |
+
state["gallery_paths"][spec.role] = gallery_path
|
| 3008 |
+
state["image_models"][spec.role] = image_model
|
| 3009 |
+
png_content = base64.b64decode(data_uri.split(",", 1)[1])
|
| 3010 |
+
state["quality"][spec.role] = validate_asset_png(png_content, spec)
|
| 3011 |
+
regenerated_roles.append(spec.role)
|
| 3012 |
+
if error:
|
| 3013 |
+
errors.append(f"{spec.role}: {error}")
|
| 3014 |
state["run_id"] = run_id
|
| 3015 |
+
rendered = render_generation_state(
|
| 3016 |
+
state,
|
| 3017 |
+
f"Regenerated {', '.join(regenerated_roles)}; retained",
|
| 3018 |
+
errors,
|
| 3019 |
+
)
|
| 3020 |
roles = [item.role for item in specs]
|
| 3021 |
+
retained_approvals = [role for role in (approved_roles or []) if role not in regenerated_roles]
|
| 3022 |
return (
|
| 3023 |
*rendered,
|
| 3024 |
state,
|
|
|
|
| 3197 |
<header class="hero">
|
| 3198 |
<div class="eyebrow">Game asset studio</div>
|
| 3199 |
<h1>Turn your game code into a visual world.</h1>
|
| 3200 |
+
<p>Define exactly which images your game needs, generate each output from its own technical contract, and embed them without rewriting your game logic.</p>
|
| 3201 |
<div class="hero-chips">
|
| 3202 |
+
<span>User-defined asset sets</span><span>Separate variations</span><span>Contract-checked PNGs</span>
|
| 3203 |
</div>
|
| 3204 |
</header>
|
| 3205 |
"""
|
|
|
|
| 3209 |
|
| 3210 |
gr.HTML(
|
| 3211 |
'<div class="step-heading"><span>Step 01</span><h2>Set up your generation</h2>'
|
| 3212 |
+
'<p>Paste the game, then describe any image types and quantities through an explicit asset manifest.</p></div>'
|
| 3213 |
)
|
| 3214 |
with gr.Row(equal_height=False):
|
| 3215 |
with gr.Column(scale=3, elem_classes=["studio-panel"]):
|
|
|
|
| 3218 |
lines=22,
|
| 3219 |
placeholder="Paste your full HTML game code here.",
|
| 3220 |
value=STARTER_HTML,
|
| 3221 |
+
info="Use GAME_ASSETS.<asset_id>, GAME_ASSETS.<asset_id>[index], or an exact manifest filename.",
|
| 3222 |
)
|
| 3223 |
with gr.Column(scale=2, elem_classes=["studio-panel"]):
|
| 3224 |
roles = gr.Textbox(
|
| 3225 |
+
label="Custom asset manifest (JSON)",
|
| 3226 |
+
lines=18,
|
| 3227 |
placeholder=ROLE_PLACEHOLDER,
|
| 3228 |
value=DEFAULT_ROLES,
|
| 3229 |
info=(
|
| 3230 |
+
"Define any asset IDs and quantities. Each output gets an independent model call. Supported "
|
| 3231 |
+
"compositions: single_subject, full_frame, seamless, icon, animation_frame, sprite_sheet, "
|
| 3232 |
+
"and freeform. The older `name: description` format remains supported."
|
| 3233 |
),
|
| 3234 |
)
|
| 3235 |
with gr.Row():
|
|
|
|
| 3271 |
|
| 3272 |
gr.HTML(
|
| 3273 |
'<div class="step-heading"><span>Step 02</span><h2>Review generated assets</h2>'
|
| 3274 |
+
'<p>Check every independently generated output against its subject, perspective, composition, and alpha contract.</p></div>'
|
| 3275 |
)
|
| 3276 |
with gr.Row(equal_height=False):
|
| 3277 |
with gr.Column(scale=3, elem_classes=["studio-panel", "gallery-panel"]):
|
| 3278 |
gallery = gr.Gallery(label="Generated assets", columns=2, height=420)
|
| 3279 |
with gr.Column(scale=2, elem_classes=["studio-panel"]):
|
| 3280 |
selected_role = gr.Dropdown(
|
| 3281 |
+
label="Output or complete asset set to regenerate",
|
| 3282 |
choices=[],
|
| 3283 |
interactive=True,
|
| 3284 |
)
|
|
|
|
| 3315 |
interactive=False,
|
| 3316 |
)
|
| 3317 |
model_report = gr.Textbox(
|
| 3318 |
+
label="Model/source used by output",
|
| 3319 |
lines=5,
|
| 3320 |
interactive=False,
|
| 3321 |
)
|