LeafCat79 commited on
Commit
20e49a4
·
verified ·
1 Parent(s): 16301e6

Add deterministic asset integration workflow

Browse files
Files changed (3) hide show
  1. .gitignore +3 -0
  2. README.md +20 -2
  3. app.py +375 -137
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
README.md CHANGED
@@ -11,9 +11,27 @@ pinned: false
11
 
12
  # Image Generator for HTML Games
13
 
14
- Paste an HTML game, describe asset roles like `player`, `background`, or `enemy`, and generate a rewritten version of the game with image data embedded directly into the HTML.
15
 
16
- The app interprets the shared art style and pasted game code into explicit per-role image prompts, then generates and embeds game assets as base64 data URIs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  If the Space has an `HF_TOKEN` secret, 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`.
19
 
 
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
+ 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 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.
35
 
36
  If the Space has an `HF_TOKEN` secret, 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`.
37
 
app.py CHANGED
@@ -105,6 +105,15 @@ class StylePlan:
105
  tags: tuple[str, ...]
106
 
107
 
 
 
 
 
 
 
 
 
 
108
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
109
  FREE_IMAGE_MODEL = os.environ.get("FREE_IMAGE_MODEL", "segmind/tiny-sd")
110
  FREE_IMAGE_STEPS = int(os.environ.get("FREE_IMAGE_STEPS", "5"))
@@ -189,10 +198,16 @@ def interpret_style_hint(style_hint: str) -> StylePlan:
189
  lighting = "balanced game lighting"
190
  linework = "clear readable silhouette and controlled edges"
191
 
192
- if has("top-down", "top down", "shooter", "rpg", "arena"):
193
  camera = "top-down readable game camera"
194
  elif has("platformer", "side-scroller", "side scroller"):
195
  camera = "side-view platformer camera"
 
 
 
 
 
 
196
  else:
197
  camera = "game-ready camera angle matching the role"
198
 
@@ -211,18 +226,31 @@ def build_asset_prompt(role: str, prompt: str, style_hint: str) -> str:
211
  )
212
  else:
213
  asset_instruction = (
214
- "Create one complete standalone 2D sprite of the whole subject, centered, full body or full vehicle, "
215
  "single object, transparent or plain background, readable silhouette. Not a texture map, not a tiled "
216
  "pattern, not a material swatch, not a UV unwrap, not a 3D model skin."
217
  )
218
  return (
219
- f"{role} asset: {prompt}. Style interpretation: {plan.medium}; {plan.palette}; "
 
220
  f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. "
221
  f"{asset_instruction} "
222
  "Game asset, readable at small size, no text, no watermark."
223
  )
224
 
225
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def parse_role_lines(raw_roles: str) -> list[tuple[str, str]]:
227
  parsed: list[tuple[str, str]] = []
228
  for line in raw_roles.splitlines():
@@ -555,7 +583,10 @@ def draw_background(spec: AssetSpec, rng: random.Random) -> bytes:
555
  draw = ImageDraw.Draw(image, "RGBA")
556
  prompt = spec.prompt.lower()
557
 
558
- if any(word in prompt for word in ("sci-fi", "scifi", "arena", "metal", "hazard", "grid", "shooter")):
 
 
 
559
  image = Image.new("RGBA", (spec.width, spec.height), (18, 22, 31, 255))
560
  draw = ImageDraw.Draw(image, "RGBA")
561
  panel = 64
@@ -750,6 +781,34 @@ def draw_sprite(spec: AssetSpec, rng: random.Random) -> bytes:
750
  accent = colors[1] + (255,)
751
  trim = colors[3] + (255,)
752
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
753
  if any(word in role_prompt for word in ("car", "racing", "racer", "buggy", "vehicle", "truck")):
754
  shadow = (int(cx - 43 * scale), int(cy + 28 * scale), int(cx + 43 * scale), int(cy + 42 * scale))
755
  draw.ellipse(shadow, fill=(0, 0, 0, 58))
@@ -1149,151 +1208,124 @@ def generate_asset(spec: AssetSpec, index: int, run_id: int) -> tuple[str, str,
1149
 
1150
  def replacement_names(spec: AssetSpec) -> set[str]:
1151
  slug = slugify(spec.role)
1152
- names = {
1153
  spec.filename,
1154
  f"{slug}.png",
1155
  f"{slug}.jpg",
1156
  f"{slug}.jpeg",
1157
  f"{slug}.webp",
1158
- f"asset_{slug}.png",
1159
- f"{spec.role.strip()}.png",
1160
  f"{{{{{slug}}}}}",
1161
  f"{{{slug}}}",
1162
  }
1163
- if slug == "background":
1164
- names.update({"background.png", "sprite_background.jpg", "background.jpg"})
1165
- if slug == "player":
1166
- names.update({"player.png", "sprite_player.jpg", "hero.png"})
1167
- if slug == "enemy":
1168
- names.update({"enemy.png", "monster.png", "sprite_enemy.jpg"})
1169
- if slug == "bullet":
1170
- names.update({"bullet.png", "projectile.png", "laser.png", "shot.png", "sprite_bullet.jpg"})
1171
- return names
1172
-
1173
-
1174
- def asset_aliases(spec: AssetSpec) -> list[str]:
1175
  slug = slugify(spec.role)
1176
- aliases = {slug, spec.role.strip().lower(), spec.filename.lower()}
1177
- prompt = spec.prompt.lower()
1178
- role_groups = {
1179
- "player": ("player", "hero", "character", "avatar", "ship", "shooter", "tank", "knight", "wizard"),
1180
- "enemy": ("enemy", "monster", "alien", "drone", "foe", "zombie", "boss", "hazard"),
1181
- "bullet": ("bullet", "projectile", "laser", "shot", "missile", "beam", "ammo"),
1182
- "coin": ("coin", "gem", "seed", "star", "pickup", "collectible", "key", "orb"),
1183
- "background": ("background", "backdrop", "bg", "map", "level", "arena", "scene", "world", "floor"),
1184
- }
1185
- for group, words in role_groups.items():
1186
- if group in slug or any(word in prompt for word in words):
1187
- aliases.update(words)
1188
- return sorted(alias for alias in aliases if alias)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1189
 
1190
 
1191
  def embed_assets(html_code: str, assets: dict[str, str], specs: list[AssetSpec]) -> str:
 
 
 
 
1192
  output = html_code
1193
  manifest_lines = ["<!-- Embedded game assets generated by Image Generator for HTML Games"]
1194
- background_uri = None
1195
  asset_map: dict[str, str] = {}
1196
- alias_map: dict[str, list[str]] = {}
1197
 
1198
  for spec in specs:
1199
  data_uri = assets[spec.role]
1200
  slug = slugify(spec.role)
1201
  asset_map[slug] = data_uri
1202
- alias_map[slug] = asset_aliases(spec)
1203
  manifest_lines.append(f"{spec.role}: {spec.filename}")
1204
- if is_background_spec(spec) and background_uri is None:
1205
- background_uri = data_uri
1206
  for name in replacement_names(spec):
1207
  output = output.replace(f'"{name}"', f'"{data_uri}"')
1208
  output = output.replace(f"'{name}'", f"'{data_uri}'")
1209
- output = output.replace(name, data_uri)
 
1210
 
1211
  manifest_lines.append("-->")
1212
  manifest = "\n".join(manifest_lines) + "\n"
1213
  asset_json = json.dumps(asset_map)
1214
- alias_json = json.dumps(alias_map)
1215
- background_json = json.dumps(background_uri)
1216
  helper_script = f"""<script>
1217
  (function () {{
1218
  var ASSETS = {asset_json};
1219
- var ALIASES = {alias_json};
1220
  window.GENERATED_GAME_ASSETS = ASSETS;
1221
-
1222
- function basename(value) {{
1223
- return String(value || "").split("?")[0].split("#")[0].split("/").pop().toLowerCase();
1224
- }}
1225
-
1226
- function pickAsset(value) {{
1227
- var text = String(value || "").toLowerCase();
1228
- if (!text || text.indexOf("data:image/") === 0) return value;
1229
- var file = basename(text);
1230
- for (var role in ASSETS) {{
1231
- var aliases = ALIASES[role] || [role];
1232
- for (var i = 0; i < aliases.length; i++) {{
1233
- var alias = String(aliases[i]).toLowerCase();
1234
- if (!alias) continue;
1235
- if (file === alias || file === alias + ".png" || file === "sprite_" + alias + ".png") return ASSETS[role];
1236
- if (file.indexOf(alias) !== -1 || text.indexOf("/" + alias) !== -1 || text.indexOf("_" + alias) !== -1) return ASSETS[role];
1237
- }}
1238
- }}
1239
- return value;
1240
- }}
1241
-
1242
- var descriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src");
1243
- if (descriptor && descriptor.set && !HTMLImageElement.prototype.__generatedAssetMapper) {{
1244
- Object.defineProperty(HTMLImageElement.prototype, "src", {{
1245
- get: function () {{ return descriptor.get.call(this); }},
1246
- set: function (value) {{ descriptor.set.call(this, pickAsset(value)); }},
1247
- configurable: true,
1248
- enumerable: descriptor.enumerable
1249
- }});
1250
- HTMLImageElement.prototype.__generatedAssetMapper = true;
1251
- }}
1252
-
1253
- var originalDrawImage = CanvasRenderingContext2D.prototype.drawImage;
1254
- if (!CanvasRenderingContext2D.prototype.__generatedAssetDrawGuard) {{
1255
- CanvasRenderingContext2D.prototype.drawImage = function (image) {{
1256
- try {{
1257
- if (image instanceof HTMLImageElement) {{
1258
- var current = image.getAttribute("src") || image.src || "";
1259
- var mapped = pickAsset(current);
1260
- if (mapped && mapped !== current) image.src = mapped;
1261
- if (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0) {{
1262
- var looksLikeBackground = /background|backdrop|scene|map|level|bg/i.test(current);
1263
- var coversCanvas = arguments.length >= 5 && arguments[1] === 0 && arguments[2] === 0 &&
1264
- arguments[3] >= this.canvas.width * 0.8 && arguments[4] >= this.canvas.height * 0.8;
1265
- if (looksLikeBackground || coversCanvas) {{
1266
- this.clearRect(0, 0, this.canvas.width, this.canvas.height);
1267
- }}
1268
- return;
1269
- }}
1270
- }}
1271
- return originalDrawImage.apply(this, arguments);
1272
- }} catch (error) {{
1273
- return;
1274
- }}
1275
- }};
1276
- CanvasRenderingContext2D.prototype.__generatedAssetDrawGuard = true;
1277
- }}
1278
-
1279
- window.addEventListener("DOMContentLoaded", function () {{
1280
- document.querySelectorAll("img").forEach(function (img) {{
1281
- var mapped = pickAsset(img.getAttribute("src") || img.src);
1282
- if (mapped !== (img.getAttribute("src") || img.src)) img.src = mapped;
1283
- }});
1284
- var background = {background_json};
1285
- if (!background) return;
1286
- document.querySelectorAll("canvas").forEach(function (canvas) {{
1287
- canvas.style.backgroundImage = "url(" + background + ")";
1288
- canvas.style.backgroundSize = "cover";
1289
- canvas.style.backgroundPosition = "center";
1290
- }});
1291
- }});
1292
  }})();
1293
  </script>"""
1294
 
1295
- if "</head>" in output:
1296
- output = output.replace("</head>", helper_script + "\n</head>", 1)
 
 
 
 
 
 
1297
  elif "<body" in output:
1298
  output = output.replace("<body", helper_script + "\n<body", 1)
1299
  else:
@@ -1325,19 +1357,102 @@ def summarize_model_sources(rows: list[tuple[str, str, str]]) -> str:
1325
  return f"prompt={', '.join(prompt_sources)}; image={', '.join(image_sources)}"
1326
 
1327
 
1328
- def generate_images_and_game(html_code: str, roles: str, style_hint: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1329
  if not html_code.strip():
1330
- return "", "Paste HTML game code first.", [], "", "", ""
1331
 
1332
- role_lines, prompt_map, prompt_model, prompt_error = build_prompt_map(html_code, roles, style_hint or "pixel art style")
1333
- specs = parse_assets(roles, style_hint or "pixel art style", prompt_map)
 
1334
  if not specs:
1335
- return html_code, "Add at least one asset role, like `player: brave knight`.", [], "", "", build_preview(html_code)
 
 
 
 
 
 
 
 
 
1336
 
1337
  assets: dict[str, str] = {}
1338
- gallery = []
 
 
1339
  errors = []
1340
- model_rows = []
1341
  run_id = time.time_ns()
1342
  if prompt_error:
1343
  errors.append(f"prompt model: {prompt_error}")
@@ -1345,19 +1460,71 @@ def generate_images_and_game(html_code: str, roles: str, style_hint: str):
1345
  for index, spec in enumerate(specs):
1346
  data_uri, gallery_path, error, image_model = generate_asset(spec, index, run_id)
1347
  assets[spec.role] = data_uri
1348
- gallery.append((gallery_path, f"{spec.role} -> {spec.filename}"))
1349
- model_rows.append((spec.role, prompt_model, image_model))
 
 
1350
  if error:
1351
  errors.append(f"{spec.role}: image model failed ({error}); used local procedural fallback")
1352
 
1353
- rewritten = embed_assets(html_code, assets, specs)
1354
- status = (
1355
- f"Generated and embedded {len(specs)} fresh asset(s) using "
1356
- f"{summarize_model_sources(model_rows)}. Run {str(run_id)[-6:]}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1357
  )
1358
- if errors:
1359
- status += "\n\n" + "\n".join(errors)
1360
- return rewritten, status, gallery, build_prompt_preview(specs), build_model_report(model_rows), build_preview(rewritten)
1361
 
1362
 
1363
  def check_hf_token() -> str:
@@ -1382,34 +1549,74 @@ def check_hf_token() -> str:
1382
  with gr.Blocks(title="Image Generator for HTML Games") as demo:
1383
  gr.Markdown(
1384
  "# Image Generator for HTML Games\n"
1385
- "Paste an HTML canvas game, list the image roles you want, and generate a rewritten version "
1386
- "with the images embedded directly into the code."
 
1387
  )
1388
 
 
 
1389
  with gr.Row():
1390
  with gr.Column(scale=1):
1391
  roles = gr.Textbox(
1392
  label="Image roles to generate",
1393
  lines=8,
1394
  placeholder=ROLE_PLACEHOLDER,
 
1395
  info="One per line: role: image description. Example: player: blue robot hero",
1396
  )
1397
- style = gr.Textbox(
1398
- label="Shared visual style",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1399
  lines=2,
1400
- placeholder="Optional: describe the art style/theme, or leave blank.",
1401
  )
1402
  generate_btn = gr.Button("Generate Images + Embed Game", variant="primary")
1403
  status = gr.Markdown("Ready.")
1404
  token_btn = gr.Button("Check HF Token")
1405
  token_status = gr.Markdown("")
1406
  gallery = gr.Gallery(label="Generated assets", columns=2, height=300)
 
 
 
 
 
 
 
 
 
 
 
1407
 
1408
  with gr.Column(scale=2):
1409
  html_input = gr.Textbox(
1410
  label="Original HTML game code",
1411
  lines=18,
1412
  placeholder="Paste your full HTML game code here.",
 
1413
  )
1414
  output_code = gr.Code(
1415
  label="Rewritten HTML with embedded images",
@@ -1426,14 +1633,45 @@ with gr.Blocks(title="Image Generator for HTML Games") as demo:
1426
  lines=5,
1427
  interactive=False,
1428
  )
 
 
 
 
 
1429
 
1430
  gr.Markdown("## Game preview")
1431
  preview = gr.HTML("")
1432
 
1433
  generate_btn.click(
1434
  fn=generate_images_and_game,
1435
- inputs=[html_input, roles, style],
1436
- outputs=[output_code, status, gallery, prompt_preview, model_report, preview],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1437
  )
1438
  token_btn.click(fn=check_hf_token, inputs=None, outputs=token_status)
1439
 
 
105
  tags: tuple[str, ...]
106
 
107
 
108
+ @dataclass
109
+ class IntegrationReport:
110
+ supported: bool
111
+ mode: str
112
+ referenced_roles: tuple[str, ...]
113
+ missing_roles: tuple[str, ...]
114
+ warnings: tuple[str, ...]
115
+
116
+
117
  HF_TOKEN = os.environ.get("HF_TOKEN", "")
118
  FREE_IMAGE_MODEL = os.environ.get("FREE_IMAGE_MODEL", "segmind/tiny-sd")
119
  FREE_IMAGE_STEPS = int(os.environ.get("FREE_IMAGE_STEPS", "5"))
 
198
  lighting = "balanced game lighting"
199
  linework = "clear readable silhouette and controlled edges"
200
 
201
+ if has("top-down", "top down", "overhead"):
202
  camera = "top-down readable game camera"
203
  elif has("platformer", "side-scroller", "side scroller"):
204
  camera = "side-view platformer camera"
205
+ elif has("isometric", "3/4 view", "three-quarter"):
206
+ camera = "consistent isometric three-quarter game camera"
207
+ elif has("first-person", "first person"):
208
+ camera = "first-person game camera"
209
+ elif has("front-facing", "front facing"):
210
+ camera = "front-facing game camera"
211
  else:
212
  camera = "game-ready camera angle matching the role"
213
 
 
226
  )
227
  else:
228
  asset_instruction = (
229
+ "Create one complete standalone 2D sprite of the whole subject, centered and fully visible, "
230
  "single object, transparent or plain background, readable silhouette. Not a texture map, not a tiled "
231
  "pattern, not a material swatch, not a UV unwrap, not a 3D model skin."
232
  )
233
  return (
234
+ f"{role} asset: {prompt}. Creative brief: {style_hint}. "
235
+ f"Style interpretation: {plan.medium}; {plan.palette}; "
236
  f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. "
237
  f"{asset_instruction} "
238
  "Game asset, readable at small size, no text, no watermark."
239
  )
240
 
241
 
242
+ def build_style_context(game_type: str, perspective: str, theme: str) -> str:
243
+ parts = []
244
+ if game_type and game_type != "Other / custom":
245
+ parts.append(f"Game type: {game_type}")
246
+ if perspective and perspective != "Auto-detect from code":
247
+ parts.append(f"Required camera perspective: {perspective}")
248
+ normalized_theme = (theme or "").strip()
249
+ if normalized_theme:
250
+ parts.append(f"Theme and visual direction: {normalized_theme}")
251
+ return ". ".join(parts) or "cohesive game-ready 2D art"
252
+
253
+
254
  def parse_role_lines(raw_roles: str) -> list[tuple[str, str]]:
255
  parsed: list[tuple[str, str]] = []
256
  for line in raw_roles.splitlines():
 
583
  draw = ImageDraw.Draw(image, "RGBA")
584
  prompt = spec.prompt.lower()
585
 
586
+ if (
587
+ any(word in prompt for word in ("sci-fi", "scifi", "arena", "metal", "hazard", "grid", "shooter"))
588
+ and not any(word in prompt for word in ("space", "starfield", "nebula", "galaxy", "cosmic"))
589
+ ):
590
  image = Image.new("RGBA", (spec.width, spec.height), (18, 22, 31, 255))
591
  draw = ImageDraw.Draw(image, "RGBA")
592
  panel = 64
 
781
  accent = colors[1] + (255,)
782
  trim = colors[3] + (255,)
783
 
784
+ if role in ("asteroid", "rock", "meteor") or any(
785
+ word in role_prompt for word in ("asteroid", "meteor", "space rock")
786
+ ):
787
+ points = []
788
+ for step in range(12):
789
+ angle = (math.pi * 2 * step / 12) - math.pi / 2
790
+ radius = rng.randint(int(34 * scale), int(49 * scale))
791
+ points.append((cx + math.cos(angle) * radius, cy + math.sin(angle) * radius))
792
+ draw.polygon(points, fill=outline)
793
+ inner = [
794
+ (cx + (x - cx) * 0.84, cy + (y - cy) * 0.84)
795
+ for x, y in points
796
+ ]
797
+ draw.polygon(inner, fill=body)
798
+ for _ in range(7):
799
+ crater_x = cx + rng.randint(-25, 25) * scale
800
+ crater_y = cy + rng.randint(-25, 25) * scale
801
+ crater_r = rng.randint(4, 11) * scale
802
+ draw.ellipse(
803
+ (crater_x - crater_r, crater_y - crater_r, crater_x + crater_r, crater_y + crater_r),
804
+ fill=outline[:3] + (105,),
805
+ outline=trim[:3] + (155,),
806
+ width=max(1, int(2 * scale)),
807
+ )
808
+ out = io.BytesIO()
809
+ image.save(out, format="PNG")
810
+ return out.getvalue()
811
+
812
  if any(word in role_prompt for word in ("car", "racing", "racer", "buggy", "vehicle", "truck")):
813
  shadow = (int(cx - 43 * scale), int(cy + 28 * scale), int(cx + 43 * scale), int(cy + 42 * scale))
814
  draw.ellipse(shadow, fill=(0, 0, 0, 58))
 
1208
 
1209
  def replacement_names(spec: AssetSpec) -> set[str]:
1210
  slug = slugify(spec.role)
1211
+ return {
1212
  spec.filename,
1213
  f"{slug}.png",
1214
  f"{slug}.jpg",
1215
  f"{slug}.jpeg",
1216
  f"{slug}.webp",
 
 
1217
  f"{{{{{slug}}}}}",
1218
  f"{{{slug}}}",
1219
  }
1220
+
1221
+
1222
+ def code_references_role(html_code: str, spec: AssetSpec) -> bool:
 
 
 
 
 
 
 
 
 
1223
  slug = slugify(spec.role)
1224
+ escaped_slug = re.escape(slug)
1225
+ hook_patterns = (
1226
+ rf"(?:window\.)?GAME_ASSETS\s*\.\s*{escaped_slug}\b",
1227
+ rf"(?:window\.)?GAME_ASSETS\s*\[\s*['\"]{escaped_slug}['\"]\s*\]",
1228
+ )
1229
+ if any(re.search(pattern, html_code, flags=re.I) for pattern in hook_patterns):
1230
+ return True
1231
+ lowered = html_code.lower()
1232
+ return any(name.lower() in lowered for name in replacement_names(spec))
1233
+
1234
+
1235
+ def analyze_integration(html_code: str, specs: list[AssetSpec]) -> IntegrationReport:
1236
+ referenced = tuple(spec.role for spec in specs if code_references_role(html_code, spec))
1237
+ missing = tuple(spec.role for spec in specs if spec.role not in referenced)
1238
+ primitive_calls = sorted(
1239
+ set(
1240
+ re.findall(
1241
+ r"\.(arc|ellipse|fillRect|strokeRect|moveTo|lineTo|fill|stroke)\s*\(",
1242
+ html_code,
1243
+ )
1244
+ )
1245
+ )
1246
+ warnings = []
1247
+ if primitive_calls:
1248
+ warnings.append(
1249
+ "Canvas primitive drawing detected ("
1250
+ + ", ".join(primitive_calls)
1251
+ + "). Primitive geometry is preserved; it is never guessed or silently replaced."
1252
+ )
1253
+ if missing:
1254
+ warnings.append(
1255
+ "Missing deterministic asset hook(s): "
1256
+ + ", ".join(f"GAME_ASSETS.{slugify(role)}" for role in missing)
1257
+ + "."
1258
+ )
1259
+ if missing:
1260
+ return IntegrationReport(False, "unsupported", referenced, missing, tuple(warnings))
1261
+ return IntegrationReport(True, "explicit asset contract", referenced, (), tuple(warnings))
1262
+
1263
+
1264
+ def validate_asset_png(content: bytes, spec: AssetSpec) -> list[str]:
1265
+ image = Image.open(io.BytesIO(content)).convert("RGBA")
1266
+ warnings = []
1267
+ if image.size != (spec.width, spec.height):
1268
+ warnings.append(f"unexpected dimensions {image.width}x{image.height}")
1269
+ alpha = image.getchannel("A")
1270
+ extrema = alpha.getextrema()
1271
+ if is_background_spec(spec):
1272
+ if extrema[0] < 255:
1273
+ warnings.append("background contains transparency")
1274
+ else:
1275
+ transparent_pixels = sum(alpha.histogram()[:16])
1276
+ transparent_ratio = transparent_pixels / max(1, image.width * image.height)
1277
+ if transparent_ratio < 0.12:
1278
+ warnings.append("sprite background is not sufficiently transparent")
1279
+ corners = (
1280
+ alpha.getpixel((0, 0)),
1281
+ alpha.getpixel((image.width - 1, 0)),
1282
+ alpha.getpixel((0, image.height - 1)),
1283
+ alpha.getpixel((image.width - 1, image.height - 1)),
1284
+ )
1285
+ if any(value > 16 for value in corners):
1286
+ warnings.append("sprite has opaque corner pixels")
1287
+ return warnings
1288
 
1289
 
1290
  def embed_assets(html_code: str, assets: dict[str, str], specs: list[AssetSpec]) -> str:
1291
+ report = analyze_integration(html_code, specs)
1292
+ if not report.supported:
1293
+ raise ValueError("Cannot embed assets without deterministic GAME_ASSETS hooks or role filenames")
1294
+
1295
  output = html_code
1296
  manifest_lines = ["<!-- Embedded game assets generated by Image Generator for HTML Games"]
 
1297
  asset_map: dict[str, str] = {}
 
1298
 
1299
  for spec in specs:
1300
  data_uri = assets[spec.role]
1301
  slug = slugify(spec.role)
1302
  asset_map[slug] = data_uri
 
1303
  manifest_lines.append(f"{spec.role}: {spec.filename}")
 
 
1304
  for name in replacement_names(spec):
1305
  output = output.replace(f'"{name}"', f'"{data_uri}"')
1306
  output = output.replace(f"'{name}'", f"'{data_uri}'")
1307
+ if name.startswith("{"):
1308
+ output = output.replace(name, data_uri)
1309
 
1310
  manifest_lines.append("-->")
1311
  manifest = "\n".join(manifest_lines) + "\n"
1312
  asset_json = json.dumps(asset_map)
 
 
1313
  helper_script = f"""<script>
1314
  (function () {{
1315
  var ASSETS = {asset_json};
1316
+ window.GAME_ASSETS = Object.freeze(Object.assign({{}}, window.GAME_ASSETS || {{}}, ASSETS));
1317
  window.GENERATED_GAME_ASSETS = ASSETS;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1318
  }})();
1319
  </script>"""
1320
 
1321
+ if re.search(r"<head(?:\s[^>]*)?>", output, flags=re.I):
1322
+ output = re.sub(
1323
+ r"(<head(?:\s[^>]*)?>)",
1324
+ lambda match: match.group(1) + "\n" + helper_script,
1325
+ output,
1326
+ count=1,
1327
+ flags=re.I,
1328
+ )
1329
  elif "<body" in output:
1330
  output = output.replace("<body", helper_script + "\n<body", 1)
1331
  else:
 
1357
  return f"prompt={', '.join(prompt_sources)}; image={', '.join(image_sources)}"
1358
 
1359
 
1360
+ def build_quality_report(specs: list[AssetSpec], quality: dict[str, list[str]]) -> str:
1361
+ lines = []
1362
+ for spec in specs:
1363
+ warnings = quality.get(spec.role, [])
1364
+ lines.append(f"{spec.role}: {'PASS' if not warnings else 'WARNING — ' + '; '.join(warnings)}")
1365
+ lines.append("Semantic subject/style accuracy still requires visual approval by a human.")
1366
+ return "\n".join(lines)
1367
+
1368
+
1369
+ def render_generation_state(state: dict, action: str, errors: list[str] | None = None):
1370
+ html_code = state["html_code"]
1371
+ specs = state["specs"]
1372
+ assets = state["assets"]
1373
+ integration = analyze_integration(html_code, specs)
1374
+ model_rows = [
1375
+ (spec.role, state["prompt_model"], state["image_models"][spec.role])
1376
+ for spec in specs
1377
+ ]
1378
+ if integration.supported:
1379
+ rewritten = embed_assets(html_code, assets, specs)
1380
+ preview_html = build_preview(rewritten)
1381
+ status = (
1382
+ f"{action} {len(specs)} asset(s) and embedded them through the deterministic "
1383
+ f"GAME_ASSETS contract using {summarize_model_sources(model_rows)}."
1384
+ )
1385
+ else:
1386
+ rewritten = ""
1387
+ preview_html = build_preview(html_code)
1388
+ status = (
1389
+ f"{action} {len(specs)} asset(s), but did not rewrite the game because deterministic "
1390
+ "asset hooks are missing. The preview below is the unchanged original game."
1391
+ )
1392
+ if integration.warnings:
1393
+ status += "\n\n" + "\n".join(f"- {warning}" for warning in integration.warnings)
1394
+ if errors:
1395
+ status += "\n\n" + "\n".join(f"- {error}" for error in errors)
1396
+ gallery = [
1397
+ (state["gallery_paths"][spec.role], f"{spec.role} -> {spec.filename}")
1398
+ for spec in specs
1399
+ ]
1400
+ return (
1401
+ rewritten,
1402
+ status,
1403
+ gallery,
1404
+ build_prompt_preview(specs),
1405
+ build_model_report(model_rows),
1406
+ build_quality_report(specs, state["quality"]),
1407
+ preview_html,
1408
+ )
1409
+
1410
+
1411
+ def empty_generation_result(message: str, html_code: str = ""):
1412
+ return (
1413
+ "",
1414
+ message,
1415
+ [],
1416
+ "",
1417
+ "",
1418
+ "",
1419
+ build_preview(html_code) if html_code.strip() else "",
1420
+ {},
1421
+ gr.Dropdown(choices=[], value=None),
1422
+ gr.CheckboxGroup(choices=[], value=[]),
1423
+ )
1424
+
1425
+
1426
+ def generate_images_and_game(
1427
+ html_code: str,
1428
+ roles: str,
1429
+ game_type: str,
1430
+ perspective: str,
1431
+ theme: str,
1432
+ ):
1433
  if not html_code.strip():
1434
+ return empty_generation_result("Paste HTML game code first.")
1435
 
1436
+ style_context = build_style_context(game_type, perspective, theme)
1437
+ role_lines, prompt_map, prompt_model, prompt_error = build_prompt_map(html_code, roles, style_context)
1438
+ specs = parse_assets(roles, style_context, prompt_map)
1439
  if not specs:
1440
+ return empty_generation_result(
1441
+ "Add at least one asset role, like `player: brave knight`.",
1442
+ html_code,
1443
+ )
1444
+ slugs = [slugify(spec.role) for spec in specs]
1445
+ if len(slugs) != len(set(slugs)):
1446
+ return empty_generation_result(
1447
+ "Each asset role must have a unique name after normalization.",
1448
+ html_code,
1449
+ )
1450
 
1451
  assets: dict[str, str] = {}
1452
+ gallery_paths: dict[str, str] = {}
1453
+ image_models: dict[str, str] = {}
1454
+ quality: dict[str, list[str]] = {}
1455
  errors = []
 
1456
  run_id = time.time_ns()
1457
  if prompt_error:
1458
  errors.append(f"prompt model: {prompt_error}")
 
1460
  for index, spec in enumerate(specs):
1461
  data_uri, gallery_path, error, image_model = generate_asset(spec, index, run_id)
1462
  assets[spec.role] = data_uri
1463
+ gallery_paths[spec.role] = gallery_path
1464
+ image_models[spec.role] = image_model
1465
+ png_content = base64.b64decode(data_uri.split(",", 1)[1])
1466
+ quality[spec.role] = validate_asset_png(png_content, spec)
1467
  if error:
1468
  errors.append(f"{spec.role}: image model failed ({error}); used local procedural fallback")
1469
 
1470
+ state = {
1471
+ "html_code": html_code,
1472
+ "specs": specs,
1473
+ "assets": assets,
1474
+ "gallery_paths": gallery_paths,
1475
+ "prompt_model": prompt_model,
1476
+ "image_models": image_models,
1477
+ "quality": quality,
1478
+ "run_id": run_id,
1479
+ }
1480
+ rendered = render_generation_state(state, "Generated", errors)
1481
+ role_choices = [spec.role for spec in specs]
1482
+ return (
1483
+ *rendered,
1484
+ state,
1485
+ gr.Dropdown(choices=role_choices, value=role_choices[0]),
1486
+ gr.CheckboxGroup(choices=role_choices, value=[]),
1487
+ )
1488
+
1489
+
1490
+ def regenerate_selected_asset(state: dict, selected_role: str, approved_roles: list[str]):
1491
+ if not state or not selected_role:
1492
+ return (
1493
+ "",
1494
+ "Generate the initial asset set before regenerating a role.",
1495
+ [],
1496
+ "",
1497
+ "",
1498
+ "",
1499
+ "",
1500
+ state or {},
1501
+ gr.CheckboxGroup(choices=[], value=[]),
1502
+ )
1503
+ specs = state["specs"]
1504
+ index = next((i for i, spec in enumerate(specs) if spec.role == selected_role), None)
1505
+ if index is None:
1506
+ rendered = render_generation_state(state, "Kept")
1507
+ roles = [spec.role for spec in specs]
1508
+ return (*rendered, state, gr.CheckboxGroup(choices=roles, value=approved_roles or []))
1509
+
1510
+ spec = specs[index]
1511
+ run_id = time.time_ns()
1512
+ data_uri, gallery_path, error, image_model = generate_asset(spec, index, run_id)
1513
+ state["assets"][selected_role] = data_uri
1514
+ state["gallery_paths"][selected_role] = gallery_path
1515
+ state["image_models"][selected_role] = image_model
1516
+ png_content = base64.b64decode(data_uri.split(",", 1)[1])
1517
+ state["quality"][selected_role] = validate_asset_png(png_content, spec)
1518
+ state["run_id"] = run_id
1519
+ errors = [f"{selected_role}: {error}"] if error else []
1520
+ rendered = render_generation_state(state, f"Regenerated {selected_role}; retained", errors)
1521
+ roles = [item.role for item in specs]
1522
+ retained_approvals = [role for role in (approved_roles or []) if role != selected_role]
1523
+ return (
1524
+ *rendered,
1525
+ state,
1526
+ gr.CheckboxGroup(choices=roles, value=retained_approvals),
1527
  )
 
 
 
1528
 
1529
 
1530
  def check_hf_token() -> str:
 
1549
  with gr.Blocks(title="Image Generator for HTML Games") as demo:
1550
  gr.Markdown(
1551
  "# Image Generator for HTML Games\n"
1552
+ "Generate game assets and embed them only when the submitted game exposes deterministic "
1553
+ "`GAME_ASSETS.<role>` hooks or exact role filenames such as `sprite_player.png`. "
1554
+ "Primitive canvas geometry is preserved and never silently guessed."
1555
  )
1556
 
1557
+ generation_state = gr.State({})
1558
+
1559
  with gr.Row():
1560
  with gr.Column(scale=1):
1561
  roles = gr.Textbox(
1562
  label="Image roles to generate",
1563
  lines=8,
1564
  placeholder=ROLE_PLACEHOLDER,
1565
+ value=DEFAULT_ROLES,
1566
  info="One per line: role: image description. Example: player: blue robot hero",
1567
  )
1568
+ game_type = gr.Dropdown(
1569
+ label="Game type",
1570
+ choices=[
1571
+ "Top-down action / RPG",
1572
+ "Platformer / side-scroller",
1573
+ "Isometric strategy / simulation",
1574
+ "First-person",
1575
+ "Card / board game",
1576
+ "Other / custom",
1577
+ ],
1578
+ value="Top-down action / RPG",
1579
+ )
1580
+ perspective = gr.Dropdown(
1581
+ label="Camera perspective",
1582
+ choices=[
1583
+ "Top-down / overhead",
1584
+ "Side view",
1585
+ "Isometric 3/4 view",
1586
+ "First-person",
1587
+ "Front-facing",
1588
+ "Auto-detect from code",
1589
+ ],
1590
+ value="Top-down / overhead",
1591
+ )
1592
+ theme = gr.Textbox(
1593
+ label="Theme and shared visual style",
1594
  lines=2,
1595
+ placeholder="Describe the setting, palette, medium, mood, and constraints.",
1596
  )
1597
  generate_btn = gr.Button("Generate Images + Embed Game", variant="primary")
1598
  status = gr.Markdown("Ready.")
1599
  token_btn = gr.Button("Check HF Token")
1600
  token_status = gr.Markdown("")
1601
  gallery = gr.Gallery(label="Generated assets", columns=2, height=300)
1602
+ selected_role = gr.Dropdown(
1603
+ label="Asset to regenerate",
1604
+ choices=[],
1605
+ interactive=True,
1606
+ )
1607
+ regenerate_btn = gr.Button("Regenerate selected asset")
1608
+ approved_roles = gr.CheckboxGroup(
1609
+ label="Visually approved assets",
1610
+ choices=[],
1611
+ info="Approve assets only after checking subject, perspective, style, and transparency.",
1612
+ )
1613
 
1614
  with gr.Column(scale=2):
1615
  html_input = gr.Textbox(
1616
  label="Original HTML game code",
1617
  lines=18,
1618
  placeholder="Paste your full HTML game code here.",
1619
+ value=STARTER_HTML,
1620
  )
1621
  output_code = gr.Code(
1622
  label="Rewritten HTML with embedded images",
 
1633
  lines=5,
1634
  interactive=False,
1635
  )
1636
+ quality_report = gr.Textbox(
1637
+ label="Automated asset validation",
1638
+ lines=6,
1639
+ interactive=False,
1640
+ )
1641
 
1642
  gr.Markdown("## Game preview")
1643
  preview = gr.HTML("")
1644
 
1645
  generate_btn.click(
1646
  fn=generate_images_and_game,
1647
+ inputs=[html_input, roles, game_type, perspective, theme],
1648
+ outputs=[
1649
+ output_code,
1650
+ status,
1651
+ gallery,
1652
+ prompt_preview,
1653
+ model_report,
1654
+ quality_report,
1655
+ preview,
1656
+ generation_state,
1657
+ selected_role,
1658
+ approved_roles,
1659
+ ],
1660
+ )
1661
+ regenerate_btn.click(
1662
+ fn=regenerate_selected_asset,
1663
+ inputs=[generation_state, selected_role, approved_roles],
1664
+ outputs=[
1665
+ output_code,
1666
+ status,
1667
+ gallery,
1668
+ prompt_preview,
1669
+ model_report,
1670
+ quality_report,
1671
+ preview,
1672
+ generation_state,
1673
+ approved_roles,
1674
+ ],
1675
  )
1676
  token_btn.click(fn=check_hf_token, inputs=None, outputs=token_status)
1677