LeafCat79 commited on
Commit
b1b939d
·
verified ·
1 Parent(s): f905190

Support fully custom asset manifests

Browse files
Files changed (2) hide show
  1. README.md +52 -25
  2. app.py +496 -178
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 camera perspective, describe asset roles like `player`, `background`, or `enemy`, and generate game-ready images. The Space rewrites the game only when every requested role has a deterministic integration point.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ## Deterministic asset contract
17
 
18
- Reference each requested role with either an exact filename or an explicit `GAME_ASSETS` hook:
19
 
20
  ```js
21
- const playerImage = new Image();
22
- playerImage.src = GAME_ASSETS.player; // or "sprite_player.png"
23
 
24
- const backgroundImage = new Image();
25
- backgroundImage.src = GAME_ASSETS.background; // or "sprite_background.png"
26
  ```
27
 
28
- The generator injects an immutable `window.GAME_ASSETS` manifest whose keys are normalized role names. It does not monkey-patch browser image APIs, guess aliases, or silently replace canvas primitives such as `fillRect()`, `arc()`, and `fill()`.
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
- ## Multiple character sprites
33
 
34
- Define each distinct character as its own role so the model produces a separate PNG and the game can address it independently:
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
- The generator runs one neural image request per role rather than intentionally combining character roles into a contact sheet. The image model can still violate the one-subject instruction, so every result requires visual review. When the submitted HTML references additional deterministic hooks such as `GAME_ASSETS.enemy_grunt`, `GAME_ASSETS.enemy_boss`, or `sprite_npc_merchant.png`, missing roles are automatically added to the generation set. Explicit role descriptions remain preferable because code identifiers alone provide limited visual direction. Repeated instances of the same enemy should reuse one role, while visually distinct character types should use separate roles.
44
 
45
- Generated sprites receive automated dimension, alpha-channel, corner-transparency, and significant-foreground-component checks. A multi-subject sprite is rejected and regenerated with a fresh model seed rather than cropped or procedurally rearranged. These checks do not establish semantic or artistic correctness, so every role also has a manual approval control and per-role regeneration.
46
 
47
- The UI reports the configured prompt and image pipeline and labels every role with its actual source. Every asset carries a normalized camera contract (`top_down`, `side_view`, `isometric`, `first_person`, or `front_view`). Top-down background prompts explicitly forbid a sky, horizon, eye-level view, and front-facing facades.
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, background, or edge constraints; sprite outputs therefore receive alpha post-processing and still require visual approval. Background and sprite diffusion runs use different negative prompts so valid characters and vehicles are not accidentally excluded from sprite generation. A procedural renderer remains available only for local development when fail-closed production mode is disabled.
50
 
51
- The app combines the game type, perspective, theme, and pasted code into explicit per-role image prompts, then generates PNG assets and embeds contract-compatible assets as base64 data URIs.
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, roles, and theme into image prompts. The default prompt model is `Qwen/Qwen2.5-Coder-7B-Instruct`. The camera contract is appended after model interpretation, so the model cannot silently remove it.
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 uses a dynamic ZeroGPU duration estimate based on the number of separate asset roles, while one-role regeneration reserves a smaller fixed window. 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.
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 = "sprite_background.png";
49
  const playerImg = new Image();
50
- playerImg.src = "sprite_player.png";
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 = """player: top-down pixel-art adventurer hero, transparent background, bright readable silhouette
87
- background: enchanted forest clearing game background, top-down view, soft moonlight, detailed but not too busy"""
88
-
89
- ROLE_PLACEHOLDER = """player: blue robot hero sprite
90
- enemy_grunt: compact orange patrol robot
91
- enemy_boss: towering red armored commander
92
- background: empty space station floor map"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- def build_asset_prompt(role: str, prompt: str, style_hint: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  plan = interpret_style_hint(style_hint)
341
- camera = normalize_camera(f"{style_hint} {prompt}")
342
- slug = slugify(role)
343
- is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level"))
344
- if is_background:
345
- asset_instruction = (
346
- "Create one complete 2D game background scene, not a texture tile, not a material sample, "
347
- "not a UV map. Full scene composition for a canvas game. Empty environment only: no player, "
348
- "no character, no creature, no vehicle, no mascot, no foreground subject."
349
- )
350
- else:
351
- asset_instruction = (
352
- "Create a complete standalone 2D game character or object as a single centered subject. "
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: {camera_prompt_contract(camera, is_background)} "
362
- f"{asset_instruction} "
 
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 line in raw_roles.splitlines():
382
- line = line.strip()
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
- return {role: build_asset_prompt(role, prompt, style_hint) for role, prompt in role_lines}
 
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 asset roles, and the shared theme/style. Return ONLY a JSON object where "
499
  "each key is the exact role name and each value is one concise text-to-image prompt. "
500
- "Each prompt must specify: subject silhouette/shape, camera angle, art style, palette, "
501
- "transparent background for sprites/items, full scene for backgrounds, no text, no watermark. "
502
- "Make different roles visually distinct and suitable for embedding in an HTML game. "
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
- slug = slugify(role)
578
- is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level"))
579
- width, height = (800, 450) if is_background else (128, 128)
580
- filename = f"sprite_{slug}.png"
581
- camera = normalize_camera(f"{style_hint} {prompt}")
582
- interpreted_prompt = (prompt_map or {}).get(role)
583
- if interpreted_prompt:
584
- full_prompt = interpreted_prompt
585
- if "camera contract:" not in interpreted_prompt.lower():
586
- full_prompt = (
587
- f"{interpreted_prompt}. Camera contract: {camera_prompt_contract(camera, is_background)} "
588
- "Game asset, no text, no watermark."
 
 
 
 
 
 
589
  )
 
 
590
  else:
591
- full_prompt = build_asset_prompt(role, prompt, style_hint)
592
- specs.append(
593
- AssetSpec(
594
- role=role,
595
- prompt=full_prompt,
596
- filename=filename,
597
- width=width,
598
- height=height,
599
- camera=camera,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  )
602
  return specs
603
 
604
 
605
  def is_background_spec(spec: AssetSpec) -> bool:
606
- slug = slugify(spec.role)
607
- return spec.width > spec.height * 2 or any(
608
- word in slug for word in ("background", "backdrop", "scene", "map", "level")
 
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
- if is_background_spec(spec):
1624
- camera_negative = {
1625
- "top_down": ", sky, horizon, eye-level view, front view, front-facing facade, vanishing point",
1626
- "isometric": ", eye-level view, front view, inconsistent perspective, horizon",
1627
- "side_view": ", overhead view, top-down view, isometric view",
1628
- }.get(spec.camera, "")
1629
  return (
1630
- "person, character, player, hero, creature, monster, vehicle, mascot, foreground subject, "
1631
- "particle effect, particles, confetti, sparkles, floating dots, glowing orbs, embers, lens flare, "
1632
- "texture map, tiled pattern, uv map, text, watermark"
1633
  + camera_negative
1634
  )
1635
- return (
1636
- "multiple subjects, duplicate character, repeated character, character sheet, model sheet, turnaround, "
1637
- "lineup, alternate views, multiple poses, multiple views, pair, group, cropped body, scenery, landscape, "
1638
- "room, trees, foliage, ground, floor, background props, drop shadow, texture map, tiled pattern, text, watermark"
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 is_background_spec(spec):
1732
- description = compact_prompt_words(spec.prompt, 34)
 
 
 
 
1733
  return (
1734
- f"{camera} Empty playable 2D game environment with connected terrain and clear gameplay space. "
1735
- f"{description}"
 
1736
  )
1737
- description = compact_prompt_words(spec.prompt, 18)
1738
- subject = spec.role.replace("_", " ")
1739
- return (
1740
- f"{camera} Single isolated {subject} subject. One complete body, one pose, centered and fully visible. "
1741
- f"Fill about eighty percent of the frame with modest even margins on a uniform white field. {description}"
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 = (1344, 768) if is_background else (768, 1024)
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 not is_background_spec(spec):
1855
  # Text-to-image models do not produce real transparency. This makes sprites
1856
- # usable by fading out colors similar to the generated corner background.
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((128, 128), Image.LANCZOS)
1868
- small = Image.new("RGBA", (128, 128), (*source_bg, 255))
1869
- offset = ((128 - contained.width) // 2, (128 - contained.height) // 2)
1870
  small.alpha_composite(contained, dest=offset)
1871
  corners = [
1872
  small.getpixel((0, 0)),
1873
- small.getpixel((127, 0)),
1874
- small.getpixel((0, 127)),
1875
- small.getpixel((127, 127)),
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 = normalize_sprite_foreground(small, spec)
 
 
 
 
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 sprite_archetype(spec) not in ("humanoid", "enemy"):
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
- escaped_slug = re.escape(slug)
 
2280
  hook_patterns = (
2281
- rf"(?:window\.)?GAME_ASSETS\s*\.\s*{escaped_slug}\b",
2282
- rf"(?:window\.)?GAME_ASSETS\s*\[\s*['\"]{escaped_slug}['\"]\s*\]",
 
 
 
 
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 is_background_spec(spec):
2327
  if extrema[0] < 255:
2328
- warnings.append("background contains transparency")
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("sprite background is not sufficiently transparent")
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("sprite has opaque corner pixels")
2342
- subject_count = estimated_foreground_subject_count(content)
2343
- if not subject_count:
2344
- warnings.append("sprite has no significant foreground subject")
2345
- elif subject_count > 1:
2346
- warnings.append(f"sprite contains {subject_count} significant foreground subjects")
2347
- elif has_implausibly_thin_foreground_subject(content, spec):
2348
- warnings.append("sprite foreground silhouette is implausibly thin")
2349
- elif has_undersized_foreground_subject(content):
2350
- warnings.append("sprite foreground occupies too little of the sprite canvas")
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- asset_map[slug] = data_uri
 
 
 
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}` · sprites: `{sprite_source}` · backgrounds: `{background_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
- "Critical camera and subject constraints are placed first inside a conservative CLIP prompt budget. "
2490
- "Sprite foregrounds are tightly normalized to a consistent game-canvas occupancy, then must pass subject-count, "
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 separate asset roles from deterministic game-code hooks: "
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
- del game_type, perspective, theme
2585
- role_count = max(1, len(resolve_role_lines(html_code or "", roles or "")[0]))
2586
- return min(120, 30 + role_count * 18)
 
 
 
 
 
 
 
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
- role_lines, prompt_map, prompt_model, prompt_error, inferred_roles = build_prompt_map(
2602
- html_code,
2603
- roles,
2604
- style_context,
2605
- )
2606
- specs = parse_assets(roles, style_context, prompt_map, role_lines)
 
 
 
2607
  if not specs:
2608
  return empty_generation_result(
2609
- "Add at least one asset role, like `player: brave knight`.",
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 = [spec.role for spec in specs]
 
2654
  return (
2655
  *rendered,
2656
  state,
2657
- gr.Dropdown(choices=role_choices, value=role_choices[0]),
2658
- gr.CheckboxGroup(choices=role_choices, value=[]),
2659
  )
2660
 
2661
 
2662
- @gpu_task(duration=45)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- index = next((i for i, spec in enumerate(specs) if spec.role == selected_role), None)
2678
- if index is None:
 
 
 
 
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
- try:
2686
- data_uri, gallery_path, error, image_model = generate_asset(spec, index, run_id)
2687
- except RuntimeError as exc:
2688
- rendered = render_generation_state(state, "Kept", [str(exc)])
2689
- roles = [item.role for item in specs]
2690
- return (
2691
- *rendered,
2692
- state,
2693
- gr.CheckboxGroup(choices=roles, value=approved_roles or []),
2694
- )
2695
- state["assets"][selected_role] = data_uri
2696
- state["gallery_paths"][selected_role] = gallery_path
2697
- state["image_models"][selected_role] = image_model
2698
- png_content = base64.b64decode(data_uri.split(",", 1)[1])
2699
- state["quality"][selected_role] = validate_asset_png(png_content, spec)
 
 
 
2700
  state["run_id"] = run_id
2701
- errors = [f"{selected_role}: {error}"] if error else []
2702
- rendered = render_generation_state(state, f"Regenerated {selected_role}; retained", errors)
 
 
 
2703
  roles = [item.role for item in specs]
2704
- retained_approvals = [role for role in (approved_roles or []) if role != selected_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>Describe the art direction, generate camera-aware assets, and embed them into deterministic image hooks—without rewriting your game logic.</p>
2884
  <div class="hero-chips">
2885
- <span>Multiple character roles</span><span>Game-ready PNGs</span><span>Safe code integration</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 first, then define the assets and shared art direction.</p></div>'
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.<role> or sprite_<role>.png for deterministic embedding.",
2905
  )
2906
  with gr.Column(scale=2, elem_classes=["studio-panel"]):
2907
  roles = gr.Textbox(
2908
- label="Assets to create",
2909
- lines=8,
2910
  placeholder=ROLE_PLACEHOLDER,
2911
  value=DEFAULT_ROLES,
2912
  info=(
2913
- "One separate role per character or asset, for example player, enemy_grunt, and enemy_boss. "
2914
- "Missing deterministic GAME_ASSETS roles are added from the submitted code."
 
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, style, and transparency before approving an asset.</p></div>'
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="Asset to regenerate",
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 role",
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
  )