LeafCat79 commited on
Commit
6906c25
verified
1 Parent(s): 68b1f7a

Generate separate roles and reject multi-view sprite sheets

Browse files
Files changed (2) hide show
  1. README.md +16 -2
  2. app.py +188 -42
README.md CHANGED
@@ -29,7 +29,20 @@ The generator injects an immutable `window.GAME_ASSETS` manifest whose keys are
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
- Generated sprites receive automated dimension, alpha-channel, and corner-transparency checks. These checks do not establish semantic or artistic correctness, so every role also has a manual approval control and per-role regeneration.
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  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.
35
 
@@ -49,7 +62,7 @@ segmind/SSD-1B + latent-consistency/lcm-lora-ssd-1b
49
 
50
  SSD-1B is an Apache-2.0 distilled SDXL model. The LCM adapter enables low-step generation. The weights run inside the Space's ZeroGPU allocation, so the app does not consume Hugging Face Inference Provider credits and does not require a paid image API.
51
 
52
- Sprites and backgrounds are generated directly from their written prompts. No procedurally drawn sprite, palette, silhouette, camera guide, or background layout is passed into the production model. Camera perspective is enforced through explicit positive and negative prompt contracts. Background negative prompts reject characters and generic particle overlays, while sprite prompts request a complete subject on a uniform white field for later transparency extraction.
53
 
54
  The deployed Space is fail-closed: if the primary neural model cannot run, generation stops with a visible error instead of silently returning procedural art and calling it model output. The procedural renderer remains available for local development and diagnostics only and cannot influence successful public generations.
55
 
@@ -64,6 +77,7 @@ PRIMARY_IMAGE_MODEL=segmind/SSD-1B
64
  PRIMARY_LORA_MODEL=latent-consistency/lcm-lora-ssd-1b
65
  PRIMARY_IMAGE_STEPS=4
66
  PRIMARY_SPRITE_STEPS=6
 
67
  PRIMARY_GUIDANCE_SCALE=1.5
68
  ```
69
 
 
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
 
 
62
 
63
  SSD-1B is an Apache-2.0 distilled SDXL model. The LCM adapter enables low-step generation. The weights run inside the Space's ZeroGPU allocation, so the app does not consume Hugging Face Inference Provider credits and does not require a paid image API.
64
 
65
+ Sprites and backgrounds are generated directly from their written prompts. No procedurally drawn sprite, palette, silhouette, camera guide, or background layout is passed into the production model. Sprite prompts use a portrait canvas and positive single-subject composition language; the negative prompt separately rejects sheets, lineups, repeated subjects, and multiple views. After transparency extraction, sprites with zero or multiple significant foreground components are regenerated with a new seed up to `PRIMARY_SPRITE_ATTEMPTS` times. If every attempt fails, the deployed Space returns an explicit model-generation error rather than returning a contact sheet. Background negative prompts reject characters and generic particle overlays.
66
 
67
  The deployed Space is fail-closed: if the primary neural model cannot run, generation stops with a visible error instead of silently returning procedural art and calling it model output. The procedural renderer remains available for local development and diagnostics only and cannot influence successful public generations.
68
 
 
77
  PRIMARY_LORA_MODEL=latent-consistency/lcm-lora-ssd-1b
78
  PRIMARY_IMAGE_STEPS=4
79
  PRIMARY_SPRITE_STEPS=6
80
+ PRIMARY_SPRITE_ATTEMPTS=3
81
  PRIMARY_GUIDANCE_SCALE=1.5
82
  ```
83
 
app.py CHANGED
@@ -87,6 +87,8 @@ DEFAULT_ROLES = """player: top-down pixel-art adventurer hero, transparent backg
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
  background: empty space station floor map"""
91
 
92
 
@@ -125,6 +127,7 @@ PRIMARY_IMAGE_MODEL = os.environ.get("PRIMARY_IMAGE_MODEL", "segmind/SSD-1B")
125
  PRIMARY_LORA_MODEL = os.environ.get("PRIMARY_LORA_MODEL", "latent-consistency/lcm-lora-ssd-1b")
126
  PRIMARY_IMAGE_STEPS = int(os.environ.get("PRIMARY_IMAGE_STEPS", "4"))
127
  PRIMARY_SPRITE_STEPS = int(os.environ.get("PRIMARY_SPRITE_STEPS", "6"))
 
128
  PRIMARY_GUIDANCE_SCALE = float(os.environ.get("PRIMARY_GUIDANCE_SCALE", "1.5"))
129
  USE_PRIMARY_IMAGE_MODEL = os.environ.get("USE_PRIMARY_IMAGE_MODEL", "1") == "1"
130
  REQUIRE_PRIMARY_IMAGE_MODEL = os.environ.get(
@@ -324,10 +327,9 @@ def build_asset_prompt(role: str, prompt: str, style_hint: str) -> str:
324
  )
325
  else:
326
  asset_instruction = (
327
- "Create exactly one complete standalone 2D game character or object, centered and fully visible. "
328
- "Show one subject in one pose from one view only; do not create a character sheet, turnaround, lineup, "
329
- "or alternate views. Use a transparent or plain background and a readable silhouette. Not a texture map, "
330
- "not a tiled pattern, not a material swatch, not a UV unwrap, not a 3D model skin."
331
  )
332
  return (
333
  f"{role} asset: {prompt}. Creative brief: {style_hint}. "
@@ -335,7 +337,7 @@ def build_asset_prompt(role: str, prompt: str, style_hint: str) -> str:
335
  f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. "
336
  f"Camera contract: {camera_prompt_contract(camera, is_background)} "
337
  f"{asset_instruction} "
338
- "Game asset, readable at small size, no text, no watermark."
339
  )
340
 
341
 
@@ -370,6 +372,52 @@ def parse_role_lines(raw_roles: str) -> list[tuple[str, str]]:
370
  return parsed
371
 
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  def infer_code_context(html_code: str) -> str:
374
  text = html_code[:12000]
375
  filenames = sorted(set(re.findall(r"['\"]([^'\"]+?\.(?:png|jpg|jpeg|webp|gif))['\"]", text, flags=re.I)))
@@ -428,7 +476,8 @@ def hf_prompt_json(html_code: str, role_lines: list[tuple[str, str]], style_hint
428
  "each key is the exact role name and each value is one concise text-to-image prompt. "
429
  "Each prompt must specify: subject silhouette/shape, camera angle, art style, palette, "
430
  "transparent background for sprites/items, full scene for backgrounds, no text, no watermark. "
431
- "Make different roles visually distinct and suitable for embedding in an HTML game."
 
432
  )
433
  user_text = (
434
  f"HTML/game context summary: {infer_code_context(html_code)}\n\n"
@@ -481,18 +530,27 @@ def hf_prompt_json(html_code: str, role_lines: list[tuple[str, str]], style_hint
481
  return None, short_error(exc)
482
 
483
 
484
- def build_prompt_map(html_code: str, raw_roles: str, style_hint: str) -> tuple[list[tuple[str, str]], dict[str, str], str, str | None]:
485
- role_lines = parse_role_lines(raw_roles)
 
 
 
 
486
  local_map = local_prompt_map(role_lines, style_hint)
487
  ai_map, prompt_error = hf_prompt_json(html_code, role_lines, style_hint)
488
  if ai_map and all(role in ai_map for role, _ in role_lines):
489
- return role_lines, ai_map, HF_PROMPT_MODEL, None
490
- return role_lines, local_map, "local prompt interpreter", prompt_error
491
 
492
 
493
- def parse_assets(raw_roles: str, style_hint: str, prompt_map: dict[str, str] | None = None) -> list[AssetSpec]:
 
 
 
 
 
494
  specs: list[AssetSpec] = []
495
- for role, prompt in parse_role_lines(raw_roles):
496
  slug = slugify(role)
497
  is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level"))
498
  width, height = (800, 450) if is_background else (128, 128)
@@ -1660,13 +1718,11 @@ def primary_diffusion_prompt(spec: AssetSpec) -> str:
1660
  "Use connected environmental forms and clean terrain structure. No characters and no decorative overlay."
1661
  )
1662
  return (
1663
- f"{spec.prompt} Exactly one isolated subject only, centered, fully visible, with one pose and one view. "
1664
- "Do not repeat the subject and do not create a character sheet, turnaround, lineup, or alternate views. "
1665
- "Use a readable silhouette and derive its anatomy, body shape, camera angle, and pose directly from "
1666
- "the written description. "
1667
- "Use a plain uniform white studio background for clean extraction. "
1668
- "Add recognizable costume, equipment, materials, and character details. "
1669
- "No scenery, floor, trees, foliage, frame, or environmental props."
1670
  )
1671
 
1672
 
@@ -1682,22 +1738,36 @@ def primary_diffusion_png(spec: AssetSpec, index: int, run_id: int) -> tuple[byt
1682
  try:
1683
  import torch
1684
 
1685
- seed = abs(hash(f"primary|{spec.role}|{spec.prompt}|{index}|{run_id}")) % 2147483647
1686
- generator = torch.Generator(device="cuda").manual_seed(seed)
1687
- common = {
1688
- "prompt": primary_diffusion_prompt(spec),
1689
- "negative_prompt": diffusion_negative_prompt(spec),
1690
- "num_inference_steps": PRIMARY_IMAGE_STEPS if is_background_spec(spec) else PRIMARY_SPRITE_STEPS,
1691
- "guidance_scale": PRIMARY_GUIDANCE_SCALE,
1692
- "generator": generator,
1693
- }
1694
- width, height = (1344, 768) if is_background_spec(spec) else (1024, 1024)
1695
- image = PRIMARY_TEXT_PIPE(
1696
- width=width,
1697
- height=height,
1698
- **common,
1699
- ).images[0]
1700
- return polish_diffusion_asset(image, spec), None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1701
  except Exception as exc:
1702
  return None, short_error(exc)
1703
 
@@ -1761,7 +1831,20 @@ def polish_diffusion_asset(image: Image.Image, spec: AssetSpec) -> bytes:
1761
  if not is_background_spec(spec):
1762
  # Text-to-image models do not produce real transparency. This makes sprites
1763
  # usable by fading out colors similar to the generated corner background.
1764
- small = image.resize((128, 128), Image.LANCZOS)
 
 
 
 
 
 
 
 
 
 
 
 
 
1765
  corners = [
1766
  small.getpixel((0, 0)),
1767
  small.getpixel((127, 0)),
@@ -1788,6 +1871,48 @@ def polish_diffusion_asset(image: Image.Image, spec: AssetSpec) -> bytes:
1788
  return out.getvalue()
1789
 
1790
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1791
  def free_diffusion_png(spec: AssetSpec, index: int, run_id: int) -> tuple[bytes | None, str | None]:
1792
  global FREE_DIFFUSION_PIPE, FREE_DIFFUSION_ERROR
1793
  if FREE_DIFFUSION_ERROR:
@@ -2033,6 +2158,11 @@ def validate_asset_png(content: bytes, spec: AssetSpec) -> list[str]:
2033
  )
2034
  if any(value > 16 for value in corners):
2035
  warnings.append("sprite has opaque corner pixels")
 
 
 
 
 
2036
  return warnings
2037
 
2038
 
@@ -2171,8 +2301,9 @@ def model_configuration_summary() -> str:
2171
  f"remote image fallback: `{remote_fallback}` 路 neural image models: `{neural_status}` 路 "
2172
  f"neural enforcement: `{enforcement}` 路 primary readiness: `{readiness}`. "
2173
  "Every production image is generated directly from its written prompt by the primary text-to-image model. "
2174
- "No procedural guide is supplied to the model. The procedural renderer is development-only and is blocked in "
2175
- "the deployed Space when the primary model fails."
 
2176
  )
2177
 
2178
 
@@ -2216,6 +2347,13 @@ def render_generation_state(state: dict, action: str, errors: list[str] | None =
2216
  )
2217
  if integration.warnings:
2218
  status += "\n\n" + "\n".join(f"- {warning}" for warning in integration.warnings)
 
 
 
 
 
 
 
2219
  if errors:
2220
  status += "\n\n" + "\n".join(f"- {error}" for error in errors)
2221
  gallery = [
@@ -2260,8 +2398,12 @@ def generate_images_and_game(
2260
  return empty_generation_result("Paste HTML game code first.")
2261
 
2262
  style_context = build_style_context(game_type, perspective, theme)
2263
- role_lines, prompt_map, prompt_model, prompt_error = build_prompt_map(html_code, roles, style_context)
2264
- specs = parse_assets(roles, style_context, prompt_map)
 
 
 
 
2265
  if not specs:
2266
  return empty_generation_result(
2267
  "Add at least one asset role, like `player: brave knight`.",
@@ -2304,6 +2446,7 @@ def generate_images_and_game(
2304
  "prompt_model": prompt_model,
2305
  "image_models": image_models,
2306
  "quality": quality,
 
2307
  "run_id": run_id,
2308
  }
2309
  rendered = render_generation_state(state, "Generated", errors)
@@ -2539,7 +2682,7 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
2539
  <h1>Turn your game code into a visual world.</h1>
2540
  <p>Describe the art direction, generate camera-aware assets, and embed them into deterministic image hooks鈥攚ithout rewriting your game logic.</p>
2541
  <div class="hero-chips">
2542
- <span>Camera-aware prompts</span><span>Game-ready PNGs</span><span>Safe code integration</span>
2543
  </div>
2544
  </header>
2545
  """
@@ -2566,7 +2709,10 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
2566
  lines=8,
2567
  placeholder=ROLE_PLACEHOLDER,
2568
  value=DEFAULT_ROLES,
2569
- info="One per line: role: image description",
 
 
 
2570
  )
2571
  with gr.Row():
2572
  game_type = gr.Dropdown(
 
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
 
 
127
  PRIMARY_LORA_MODEL = os.environ.get("PRIMARY_LORA_MODEL", "latent-consistency/lcm-lora-ssd-1b")
128
  PRIMARY_IMAGE_STEPS = int(os.environ.get("PRIMARY_IMAGE_STEPS", "4"))
129
  PRIMARY_SPRITE_STEPS = int(os.environ.get("PRIMARY_SPRITE_STEPS", "6"))
130
+ PRIMARY_SPRITE_ATTEMPTS = max(1, int(os.environ.get("PRIMARY_SPRITE_ATTEMPTS", "3")))
131
  PRIMARY_GUIDANCE_SCALE = float(os.environ.get("PRIMARY_GUIDANCE_SCALE", "1.5"))
132
  USE_PRIMARY_IMAGE_MODEL = os.environ.get("USE_PRIMARY_IMAGE_MODEL", "1") == "1"
133
  REQUIRE_PRIMARY_IMAGE_MODEL = os.environ.get(
 
327
  )
328
  else:
329
  asset_instruction = (
330
+ "Create a complete standalone 2D game character or object as a single centered subject. "
331
+ "Present one complete body in one pose from the required camera direction, fully visible with broad empty "
332
+ "margin on every side. Use a clean uniform white studio field and a readable game-scale silhouette."
 
333
  )
334
  return (
335
  f"{role} asset: {prompt}. Creative brief: {style_hint}. "
 
337
  f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. "
338
  f"Camera contract: {camera_prompt_contract(camera, is_background)} "
339
  f"{asset_instruction} "
340
+ "Game asset, readable at small size, with a clean unlabeled presentation."
341
  )
342
 
343
 
 
372
  return parsed
373
 
374
 
375
+ def extract_code_asset_roles(html_code: str) -> list[str]:
376
+ """Return deterministic GAME_ASSETS and sprite filename roles in source order."""
377
+ matches: list[tuple[int, str]] = []
378
+ patterns = (
379
+ r"(?:window\.)?GAME_ASSETS\s*\.\s*([a-zA-Z_$][\w$]*)",
380
+ r"(?:window\.)?GAME_ASSETS\s*\[\s*['\"]([a-zA-Z0-9_-]+)['\"]\s*\]",
381
+ r"['\"]sprite_([a-zA-Z0-9_-]+)\.(?:png|jpg|jpeg|webp|gif)['\"]",
382
+ )
383
+ for pattern in patterns:
384
+ for match in re.finditer(pattern, html_code, flags=re.I):
385
+ matches.append((match.start(), slugify(match.group(1))))
386
+
387
+ roles: list[str] = []
388
+ seen: set[str] = set()
389
+ for _, role in sorted(matches, key=lambda item: item[0]):
390
+ if role not in seen:
391
+ seen.add(role)
392
+ roles.append(role)
393
+ return roles
394
+
395
+
396
+ def inferred_role_prompt(role: str) -> str:
397
+ """Create a conservative prompt when code exposes a role without an art brief."""
398
+ slug = slugify(role)
399
+ label = slug.replace("_", " ")
400
+ if any(word in slug for word in ("background", "backdrop", "scene", "map", "level")):
401
+ return f"complete {label} game environment"
402
+ if any(word in slug for word in ("player", "hero", "enemy", "boss", "npc", "character", "ally", "companion")):
403
+ return f"distinct full-body {label} character"
404
+ return f"distinct {label} game asset"
405
+
406
+
407
+ def resolve_role_lines(html_code: str, raw_roles: str) -> tuple[list[tuple[str, str]], list[str]]:
408
+ """Merge user-authored roles with missing deterministic roles found in game code."""
409
+ role_lines = parse_role_lines(raw_roles)
410
+ seen = {slugify(role) for role, _ in role_lines}
411
+ inferred: list[str] = []
412
+ for role in extract_code_asset_roles(html_code):
413
+ if role in seen:
414
+ continue
415
+ seen.add(role)
416
+ inferred.append(role)
417
+ role_lines.append((role, inferred_role_prompt(role)))
418
+ return role_lines, inferred
419
+
420
+
421
  def infer_code_context(html_code: str) -> str:
422
  text = html_code[:12000]
423
  filenames = sorted(set(re.findall(r"['\"]([^'\"]+?\.(?:png|jpg|jpeg|webp|gif))['\"]", text, flags=re.I)))
 
476
  "each key is the exact role name and each value is one concise text-to-image prompt. "
477
  "Each prompt must specify: subject silhouette/shape, camera angle, art style, palette, "
478
  "transparent background for sprites/items, full scene for backgrounds, no text, no watermark. "
479
+ "Make different roles visually distinct and suitable for embedding in an HTML game. "
480
+ "Describe only one role per prompt; never combine multiple character roles into one image."
481
  )
482
  user_text = (
483
  f"HTML/game context summary: {infer_code_context(html_code)}\n\n"
 
530
  return None, short_error(exc)
531
 
532
 
533
+ def build_prompt_map(
534
+ html_code: str,
535
+ raw_roles: str,
536
+ style_hint: str,
537
+ ) -> tuple[list[tuple[str, str]], dict[str, str], str, str | None, list[str]]:
538
+ role_lines, inferred_roles = resolve_role_lines(html_code, raw_roles)
539
  local_map = local_prompt_map(role_lines, style_hint)
540
  ai_map, prompt_error = hf_prompt_json(html_code, role_lines, style_hint)
541
  if ai_map and all(role in ai_map for role, _ in role_lines):
542
+ return role_lines, ai_map, HF_PROMPT_MODEL, None, inferred_roles
543
+ return role_lines, local_map, "local prompt interpreter", prompt_error, inferred_roles
544
 
545
 
546
+ def parse_assets(
547
+ raw_roles: str,
548
+ style_hint: str,
549
+ prompt_map: dict[str, str] | None = None,
550
+ role_lines: list[tuple[str, str]] | None = None,
551
+ ) -> list[AssetSpec]:
552
  specs: list[AssetSpec] = []
553
+ for role, prompt in role_lines if role_lines is not None else parse_role_lines(raw_roles):
554
  slug = slugify(role)
555
  is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level"))
556
  width, height = (800, 450) if is_background else (128, 128)
 
1718
  "Use connected environmental forms and clean terrain structure. No characters and no decorative overlay."
1719
  )
1720
  return (
1721
+ f"{spec.prompt} Production-ready isolated 2D game asset. A single centered subject fills roughly seventy "
1722
+ "percent of a portrait frame. One complete body, one pose, and one strict camera direction are fully visible "
1723
+ "with broad empty margin on every side. Derive the anatomy, body shape, pose, costume, equipment, materials, "
1724
+ "and recognizable details directly from the written description. Use a plain uniform white studio field, a "
1725
+ "readable silhouette, and a clean unlabeled presentation."
 
 
1726
  )
1727
 
1728
 
 
1738
  try:
1739
  import torch
1740
 
1741
+ is_background = is_background_spec(spec)
1742
+ attempts = 1 if is_background else PRIMARY_SPRITE_ATTEMPTS
1743
+ width, height = (1344, 768) if is_background else (768, 1024)
1744
+ last_component_count = 0
1745
+ for attempt in range(attempts):
1746
+ seed = abs(hash(f"primary|{spec.role}|{spec.prompt}|{index}|{run_id}|{attempt}")) % 2147483647
1747
+ generator = torch.Generator(device="cuda").manual_seed(seed)
1748
+ image = PRIMARY_TEXT_PIPE(
1749
+ prompt=primary_diffusion_prompt(spec),
1750
+ negative_prompt=diffusion_negative_prompt(spec),
1751
+ num_inference_steps=PRIMARY_IMAGE_STEPS if is_background else PRIMARY_SPRITE_STEPS,
1752
+ guidance_scale=PRIMARY_GUIDANCE_SCALE,
1753
+ generator=generator,
1754
+ num_images_per_prompt=1,
1755
+ width=width,
1756
+ height=height,
1757
+ ).images[0]
1758
+ content = polish_diffusion_asset(image, spec)
1759
+ if is_background:
1760
+ return content, None
1761
+
1762
+ last_component_count = len(significant_foreground_component_areas(content))
1763
+ if last_component_count == 1:
1764
+ return content, None
1765
+
1766
+ if last_component_count > 1:
1767
+ detail = f"the last output contained {last_component_count} significant foreground subjects"
1768
+ else:
1769
+ detail = "the last output did not contain one significant foreground subject"
1770
+ return None, f"single-subject validation failed after {attempts} model attempts; {detail}"
1771
  except Exception as exc:
1772
  return None, short_error(exc)
1773
 
 
1831
  if not is_background_spec(spec):
1832
  # Text-to-image models do not produce real transparency. This makes sprites
1833
  # usable by fading out colors similar to the generated corner background.
1834
+ # Preserve the model's portrait composition instead of stretching it square.
1835
+ corner_source = image.resize((64, 64), Image.LANCZOS)
1836
+ source_corners = [
1837
+ corner_source.getpixel((0, 0)),
1838
+ corner_source.getpixel((63, 0)),
1839
+ corner_source.getpixel((0, 63)),
1840
+ corner_source.getpixel((63, 63)),
1841
+ ]
1842
+ source_bg = tuple(sum(pixel[i] for pixel in source_corners) // len(source_corners) for i in range(3))
1843
+ contained = image.copy()
1844
+ contained.thumbnail((128, 128), Image.LANCZOS)
1845
+ small = Image.new("RGBA", (128, 128), (*source_bg, 255))
1846
+ offset = ((128 - contained.width) // 2, (128 - contained.height) // 2)
1847
+ small.alpha_composite(contained, dest=offset)
1848
  corners = [
1849
  small.getpixel((0, 0)),
1850
  small.getpixel((127, 0)),
 
1871
  return out.getvalue()
1872
 
1873
 
1874
+ def significant_foreground_component_areas(content: bytes) -> list[int]:
1875
+ """Return material alpha components, ignoring small detached details and cleanup noise."""
1876
+ image = Image.open(io.BytesIO(content)).convert("RGBA")
1877
+ width, height = image.size
1878
+ alpha = image.getchannel("A").tobytes()
1879
+ foreground = bytearray(1 if value >= 64 else 0 for value in alpha)
1880
+ areas: list[int] = []
1881
+
1882
+ for start in range(width * height):
1883
+ if not foreground[start]:
1884
+ continue
1885
+ foreground[start] = 0
1886
+ stack = [start]
1887
+ area = 0
1888
+ while stack:
1889
+ current = stack.pop()
1890
+ area += 1
1891
+ x = current % width
1892
+ y = current // width
1893
+ for dy in (-1, 0, 1):
1894
+ ny = y + dy
1895
+ if ny < 0 or ny >= height:
1896
+ continue
1897
+ for dx in (-1, 0, 1):
1898
+ if dx == 0 and dy == 0:
1899
+ continue
1900
+ nx = x + dx
1901
+ if nx < 0 or nx >= width:
1902
+ continue
1903
+ neighbor = ny * width + nx
1904
+ if foreground[neighbor]:
1905
+ foreground[neighbor] = 0
1906
+ stack.append(neighbor)
1907
+ areas.append(area)
1908
+
1909
+ if not areas:
1910
+ return []
1911
+ largest = max(areas)
1912
+ minimum = max(64, int(largest * 0.22), int(width * height * 0.005))
1913
+ return sorted((area for area in areas if area >= minimum), reverse=True)
1914
+
1915
+
1916
  def free_diffusion_png(spec: AssetSpec, index: int, run_id: int) -> tuple[bytes | None, str | None]:
1917
  global FREE_DIFFUSION_PIPE, FREE_DIFFUSION_ERROR
1918
  if FREE_DIFFUSION_ERROR:
 
2158
  )
2159
  if any(value > 16 for value in corners):
2160
  warnings.append("sprite has opaque corner pixels")
2161
+ component_areas = significant_foreground_component_areas(content)
2162
+ if not component_areas:
2163
+ warnings.append("sprite has no significant foreground subject")
2164
+ elif len(component_areas) > 1:
2165
+ warnings.append(f"sprite contains {len(component_areas)} significant foreground subjects")
2166
  return warnings
2167
 
2168
 
 
2301
  f"remote image fallback: `{remote_fallback}` 路 neural image models: `{neural_status}` 路 "
2302
  f"neural enforcement: `{enforcement}` 路 primary readiness: `{readiness}`. "
2303
  "Every production image is generated directly from its written prompt by the primary text-to-image model. "
2304
+ "Sprite outputs must pass single-subject alpha-component validation and are regenerated with a new seed up to "
2305
+ f"{PRIMARY_SPRITE_ATTEMPTS} times. No procedural guide is supplied to the model. The procedural renderer is "
2306
+ "development-only and is blocked in the deployed Space when the primary model fails."
2307
  )
2308
 
2309
 
 
2347
  )
2348
  if integration.warnings:
2349
  status += "\n\n" + "\n".join(f"- {warning}" for warning in integration.warnings)
2350
+ inferred_roles = state.get("inferred_roles", [])
2351
+ if inferred_roles:
2352
+ status += (
2353
+ "\n\n- Added separate asset roles from deterministic game-code hooks: "
2354
+ + ", ".join(inferred_roles)
2355
+ + ". Add explicit descriptions for these roles to improve art direction."
2356
+ )
2357
  if errors:
2358
  status += "\n\n" + "\n".join(f"- {error}" for error in errors)
2359
  gallery = [
 
2398
  return empty_generation_result("Paste HTML game code first.")
2399
 
2400
  style_context = build_style_context(game_type, perspective, theme)
2401
+ role_lines, prompt_map, prompt_model, prompt_error, inferred_roles = build_prompt_map(
2402
+ html_code,
2403
+ roles,
2404
+ style_context,
2405
+ )
2406
+ specs = parse_assets(roles, style_context, prompt_map, role_lines)
2407
  if not specs:
2408
  return empty_generation_result(
2409
  "Add at least one asset role, like `player: brave knight`.",
 
2446
  "prompt_model": prompt_model,
2447
  "image_models": image_models,
2448
  "quality": quality,
2449
+ "inferred_roles": inferred_roles,
2450
  "run_id": run_id,
2451
  }
2452
  rendered = render_generation_state(state, "Generated", errors)
 
2682
  <h1>Turn your game code into a visual world.</h1>
2683
  <p>Describe the art direction, generate camera-aware assets, and embed them into deterministic image hooks鈥攚ithout rewriting your game logic.</p>
2684
  <div class="hero-chips">
2685
+ <span>Multiple character roles</span><span>Game-ready PNGs</span><span>Safe code integration</span>
2686
  </div>
2687
  </header>
2688
  """
 
2709
  lines=8,
2710
  placeholder=ROLE_PLACEHOLDER,
2711
  value=DEFAULT_ROLES,
2712
+ info=(
2713
+ "One separate role per character or asset, for example player, enemy_grunt, and enemy_boss. "
2714
+ "Missing deterministic GAME_ASSETS roles are added from the submitted code."
2715
+ ),
2716
  )
2717
  with gr.Row():
2718
  game_type = gr.Dropdown(