| import base64 |
| import io |
| import re |
| import tempfile |
| import time |
| from dataclasses import dataclass |
| from html import escape |
| from urllib.parse import quote |
|
|
| import gradio as gr |
| import requests |
| from PIL import Image, ImageDraw, ImageFont |
|
|
|
|
| POLLINATIONS_URL = ( |
| "https://image.pollinations.ai/prompt/{prompt}" |
| "?width={width}&height={height}&model=flux&nologo=true&seed={seed}" |
| ) |
|
|
|
|
| STARTER_HTML = """<!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="UTF-8" /> |
| <title>Mini Forest Game</title> |
| <style> |
| body { margin: 0; background: #111; display: grid; place-items: center; min-height: 100vh; } |
| canvas { border: 2px solid #222; background: #222; image-rendering: pixelated; } |
| </style> |
| </head> |
| <body> |
| <canvas id="gameCanvas" width="800" height="450"></canvas> |
| <script> |
| const canvas = document.getElementById("gameCanvas"); |
| const ctx = canvas.getContext("2d"); |
| |
| const background = new Image(); |
| background.src = "sprite_background.png"; |
| const playerImg = new Image(); |
| playerImg.src = "sprite_player.png"; |
| |
| const keys = new Set(); |
| const player = { x: 380, y: 205, w: 48, h: 48, speed: 4 }; |
| |
| window.addEventListener("keydown", e => keys.add(e.key)); |
| window.addEventListener("keyup", e => keys.delete(e.key)); |
| |
| function update() { |
| if (keys.has("a") || keys.has("ArrowLeft")) player.x -= player.speed; |
| if (keys.has("d") || keys.has("ArrowRight")) player.x += player.speed; |
| if (keys.has("w") || keys.has("ArrowUp")) player.y -= player.speed; |
| if (keys.has("s") || keys.has("ArrowDown")) player.y += player.speed; |
| player.x = Math.max(0, Math.min(canvas.width - player.w, player.x)); |
| player.y = Math.max(0, Math.min(canvas.height - player.h, player.y)); |
| } |
| |
| function draw() { |
| ctx.drawImage(background, 0, 0, canvas.width, canvas.height); |
| ctx.drawImage(playerImg, player.x, player.y, player.w, player.h); |
| ctx.fillStyle = "white"; |
| ctx.font = "18px sans-serif"; |
| ctx.fillText("Use WASD or arrow keys", 18, 28); |
| } |
| |
| function loop() { |
| update(); |
| draw(); |
| requestAnimationFrame(loop); |
| } |
| loop(); |
| </script> |
| </body> |
| </html>""" |
|
|
|
|
| DEFAULT_ROLES = """player: top-down pixel-art adventurer hero, transparent background, bright readable silhouette |
| background: enchanted forest clearing game background, top-down view, soft moonlight, detailed but not too busy""" |
|
|
|
|
| @dataclass |
| class AssetSpec: |
| role: str |
| prompt: str |
| filename: str |
| width: int |
| height: int |
|
|
|
|
| def slugify(value: str) -> str: |
| value = re.sub(r"[^a-zA-Z0-9]+", "_", value.strip().lower()).strip("_") |
| return value or "asset" |
|
|
|
|
| def parse_assets(raw_roles: str, style_hint: str) -> list[AssetSpec]: |
| specs: list[AssetSpec] = [] |
| for line in raw_roles.splitlines(): |
| line = line.strip() |
| if not line or line.startswith("#"): |
| continue |
|
|
| if ":" in line: |
| role, prompt = line.split(":", 1) |
| elif "=" in line: |
| role, prompt = line.split("=", 1) |
| else: |
| role, prompt = line, line |
|
|
| role = role.strip() |
| prompt = prompt.strip() or role |
| slug = slugify(role) |
| is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level")) |
| width, height = (800, 450) if is_background else (128, 128) |
| filename = f"sprite_{slug}.png" |
| full_prompt = ( |
| f"{prompt}, {style_hint}, game asset, clean readable shape, " |
| "no text, no watermark" |
| ).strip(", ") |
| specs.append(AssetSpec(role=role, prompt=full_prompt, filename=filename, width=width, height=height)) |
| return specs |
|
|
|
|
| def image_to_png_bytes(content: bytes, width: int, height: int) -> bytes: |
| image = Image.open(io.BytesIO(content)).convert("RGBA") |
| image = image.resize((width, height), Image.LANCZOS) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
|
|
| def png_bytes_to_data_uri(content: bytes) -> str: |
| return "data:image/png;base64," + base64.b64encode(content).decode("ascii") |
|
|
|
|
| def write_gallery_image(content: bytes, role: str) -> str: |
| handle = tempfile.NamedTemporaryFile( |
| prefix=f"{slugify(role)}_", |
| suffix=".png", |
| delete=False, |
| ) |
| handle.write(content) |
| handle.close() |
| return handle.name |
|
|
|
|
| def placeholder_png_bytes(role: str, width: int, height: int) -> bytes: |
| label = escape(role[:18]) |
| image = Image.new("RGBA", (width, height), "#222222") |
| draw = ImageDraw.Draw(image) |
| draw.rounded_rectangle((4, 4, width - 4, height - 4), radius=12, fill="#3b82f6") |
| font = ImageFont.load_default() |
| bbox = draw.textbbox((0, 0), label, font=font) |
| x = (width - (bbox[2] - bbox[0])) / 2 |
| y = (height - (bbox[3] - bbox[1])) / 2 |
| draw.text((x, y), label, fill="white", font=font) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
|
|
| def generate_asset(spec: AssetSpec, index: int) -> tuple[str, str, str | None]: |
| seed = abs(hash((spec.role, spec.prompt, index))) % 999999 |
| url = POLLINATIONS_URL.format( |
| prompt=quote(spec.prompt), |
| width=spec.width, |
| height=spec.height, |
| seed=seed, |
| ) |
|
|
| for attempt in range(3): |
| try: |
| response = requests.get(url, timeout=120) |
| response.raise_for_status() |
| png_content = image_to_png_bytes(response.content, spec.width, spec.height) |
| return ( |
| png_bytes_to_data_uri(png_content), |
| write_gallery_image(png_content, spec.role), |
| None, |
| ) |
| except Exception as exc: |
| if attempt < 2: |
| time.sleep(8) |
| else: |
| png_content = placeholder_png_bytes(spec.role, spec.width, spec.height) |
| return ( |
| png_bytes_to_data_uri(png_content), |
| write_gallery_image(png_content, spec.role), |
| str(exc), |
| ) |
| png_content = placeholder_png_bytes(spec.role, spec.width, spec.height) |
| return ( |
| png_bytes_to_data_uri(png_content), |
| write_gallery_image(png_content, spec.role), |
| "Unknown generation error", |
| ) |
|
|
|
|
| def replacement_names(spec: AssetSpec) -> set[str]: |
| slug = slugify(spec.role) |
| names = { |
| spec.filename, |
| f"{slug}.png", |
| f"{slug}.jpg", |
| f"{slug}.jpeg", |
| f"{slug}.webp", |
| f"asset_{slug}.png", |
| f"{spec.role.strip()}.png", |
| f"{{{{{slug}}}}}", |
| f"{{{slug}}}", |
| } |
| if slug == "background": |
| names.update({"background.png", "sprite_background.jpg", "background.jpg"}) |
| if slug == "player": |
| names.update({"player.png", "sprite_player.jpg", "hero.png"}) |
| if slug == "enemy": |
| names.update({"enemy.png", "monster.png", "sprite_enemy.jpg"}) |
| return names |
|
|
|
|
| def embed_assets(html_code: str, assets: dict[str, str], specs: list[AssetSpec]) -> str: |
| output = html_code |
| manifest_lines = ["<!-- Embedded game assets generated by Image Generator for HTML Games"] |
|
|
| for spec in specs: |
| data_uri = assets[spec.role] |
| manifest_lines.append(f"{spec.role}: {spec.filename}") |
| for name in replacement_names(spec): |
| output = output.replace(f'"{name}"', f'"{data_uri}"') |
| output = output.replace(f"'{name}'", f"'{data_uri}'") |
| output = output.replace(name, data_uri) |
|
|
| manifest_lines.append("-->") |
| manifest = "\n".join(manifest_lines) + "\n" |
| asset_map = ",\n".join( |
| f" {slugify(spec.role)!r}: {assets[spec.role]!r}" for spec in specs |
| ) |
| helper_script = f"""<script> |
| window.GENERATED_GAME_ASSETS = {{ |
| {asset_map} |
| }}; |
| </script>""" |
|
|
| if "</head>" in output: |
| output = output.replace("</head>", helper_script + "\n</head>", 1) |
| elif "<body" in output: |
| output = output.replace("<body", helper_script + "\n<body", 1) |
| else: |
| output = helper_script + "\n" + output |
|
|
| return manifest + output |
|
|
|
|
| def build_preview(html_code: str) -> str: |
| encoded = base64.b64encode(html_code.encode("utf-8")).decode("ascii") |
| return ( |
| f'<iframe src="data:text/html;base64,{encoded}" ' |
| 'style="width:100%;height:560px;border:1px solid #333;border-radius:8px;background:#000;" ' |
| 'sandbox="allow-scripts" title="Game preview"></iframe>' |
| ) |
|
|
|
|
| def generate_images_and_game(html_code: str, roles: str, style_hint: str): |
| if not html_code.strip(): |
| return "", "Paste HTML game code first.", [], "" |
|
|
| specs = parse_assets(roles, style_hint or "pixel art style") |
| if not specs: |
| return html_code, "Add at least one asset role, like `player: brave knight`.", [], build_preview(html_code) |
|
|
| assets: dict[str, str] = {} |
| gallery = [] |
| errors = [] |
|
|
| for index, spec in enumerate(specs): |
| data_uri, gallery_path, error = generate_asset(spec, index) |
| assets[spec.role] = data_uri |
| gallery.append((gallery_path, f"{spec.role} -> {spec.filename}")) |
| if error: |
| errors.append(f"{spec.role}: fallback used ({error})") |
|
|
| rewritten = embed_assets(html_code, assets, specs) |
| status = f"Generated and embedded {len(specs)} asset(s)." |
| if errors: |
| status += "\n\n" + "\n".join(errors) |
| return rewritten, status, gallery, build_preview(rewritten) |
|
|
|
|
| with gr.Blocks(title="Image Generator for HTML Games") as demo: |
| gr.Markdown( |
| "# Image Generator for HTML Games\n" |
| "Paste an HTML canvas game, list the image roles you want, and generate a rewritten version " |
| "with the images embedded directly into the code." |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| roles = gr.Textbox( |
| label="Image roles to generate", |
| value=DEFAULT_ROLES, |
| lines=8, |
| info="One per line: role: image description. Example: player: blue robot hero", |
| ) |
| style = gr.Textbox( |
| label="Shared visual style", |
| value="cohesive 2D pixel-art game style, transparent subject sprites where possible", |
| lines=2, |
| ) |
| generate_btn = gr.Button("Generate Images + Embed Game", variant="primary") |
| status = gr.Markdown("Ready.") |
| gallery = gr.Gallery(label="Generated assets", columns=2, height=300) |
|
|
| with gr.Column(scale=2): |
| html_input = gr.Code( |
| label="Original HTML game code", |
| value=STARTER_HTML, |
| language="html", |
| lines=18, |
| ) |
| output_code = gr.Code( |
| label="Rewritten HTML with embedded images", |
| language="html", |
| lines=18, |
| ) |
|
|
| gr.Markdown("## Game preview") |
| preview = gr.HTML(build_preview(STARTER_HTML)) |
|
|
| generate_btn.click( |
| fn=generate_images_and_game, |
| inputs=[html_input, roles, style], |
| outputs=[output_code, status, gallery, preview], |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|