| import base64 |
| import hashlib |
| import io |
| import json |
| import math |
| import os |
| import random |
| import re |
| import tempfile |
| import time |
| from dataclasses import dataclass |
| from html import escape |
| from urllib.parse import quote |
| from urllib.error import HTTPError, URLError |
| from urllib.request import Request, urlopen |
|
|
| import gradio as gr |
| from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont |
|
|
| try: |
| from huggingface_hub import InferenceClient |
| except Exception: |
| InferenceClient = None |
|
|
|
|
| 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""" |
|
|
| ROLE_PLACEHOLDER = """player: blue robot hero sprite |
| background: empty space station floor map""" |
|
|
|
|
| @dataclass |
| class AssetSpec: |
| role: str |
| prompt: str |
| filename: str |
| width: int |
| height: int |
|
|
|
|
| @dataclass |
| class StylePlan: |
| medium: str |
| palette: str |
| texture: str |
| lighting: str |
| linework: str |
| camera: str |
| tags: tuple[str, ...] |
|
|
|
|
| HF_TOKEN = os.environ.get("HF_TOKEN", "") |
| FREE_IMAGE_MODEL = os.environ.get("FREE_IMAGE_MODEL", "segmind/tiny-sd") |
| FREE_IMAGE_STEPS = int(os.environ.get("FREE_IMAGE_STEPS", "5")) |
| USE_HF_PROMPT_PROVIDER = os.environ.get("USE_HF_PROMPT_PROVIDER", "0") == "1" |
| USE_HF_IMAGE_PROVIDER = os.environ.get("USE_HF_IMAGE_PROVIDER", "0") == "1" |
| USE_DIFFUSION_FOR_SPRITES = os.environ.get("USE_DIFFUSION_FOR_SPRITES", "0") == "1" |
| USE_DIFFUSION_FOR_BACKGROUNDS = os.environ.get("USE_DIFFUSION_FOR_BACKGROUNDS", "0") == "1" |
| HF_IMAGE_MODEL = os.environ.get("HF_IMAGE_MODEL", "black-forest-labs/FLUX.1-schnell") |
| HF_PROMPT_MODEL = os.environ.get("HF_PROMPT_MODEL", "Qwen/Qwen2.5-Coder-7B-Instruct:fastest") |
| HF_PROMPT_ENDPOINT = os.environ.get("HF_PROMPT_ENDPOINT", "https://router.huggingface.co/v1/chat/completions") |
| FREE_DIFFUSION_PIPE = None |
| FREE_DIFFUSION_ERROR = None |
|
|
|
|
| def slugify(value: str) -> str: |
| value = re.sub(r"[^a-zA-Z0-9]+", "_", value.strip().lower()).strip("_") |
| return value or "asset" |
|
|
|
|
| def interpret_style_hint(style_hint: str) -> StylePlan: |
| text = (style_hint or "").lower() |
| tags: list[str] = [] |
|
|
| def has(*words: str) -> bool: |
| return any(word in text for word in words) |
|
|
| if has("watercolor", "ink wash", "gouache", "paper"): |
| tags.append("watercolor") |
| medium = "watercolor game illustration on textured paper" |
| palette = "soft layered washes, muted pigments, gentle color bleed" |
| texture = "visible paper grain, translucent edges, organic brush pooling" |
| lighting = "soft ambient light with low contrast" |
| linework = "loose ink accents, imperfect hand-painted contour lines" |
| elif has("vector", "flat", "icon", "logo"): |
| tags.append("vector") |
| medium = "clean vector game art" |
| palette = "flat separated color fields with bold accent colors" |
| texture = "smooth fills, no painterly grain" |
| lighting = "graphic cel shading with crisp highlights" |
| linework = "thick precise outlines and hard-edged silhouettes" |
| elif has("clay", "claymation", "stop motion", "plasticine"): |
| tags.append("clay") |
| medium = "claymation stop-motion game asset" |
| palette = "chunky colored clay with warm handmade tones" |
| texture = "fingerprint-like clay bumps, rounded sculpted forms" |
| lighting = "soft studio light with small specular highlights" |
| linework = "no ink outlines, shape defined by soft shadows" |
| elif has("oil", "painterly", "impasto", "canvas"): |
| tags.append("oil") |
| medium = "oil-painted fantasy game art" |
| palette = "rich blended colors with deep shadows" |
| texture = "visible bristle strokes and canvas-like texture" |
| lighting = "dramatic directional light" |
| linework = "painted edges instead of hard outlines" |
| elif has("pixel", "8-bit", "16-bit", "retro"): |
| tags.append("pixel") |
| medium = "retro pixel-art game asset" |
| palette = "limited palette with readable color ramps" |
| texture = "sharp square pixels and no anti-aliasing" |
| lighting = "simple two-tone pixel shading" |
| linework = "one-pixel dark outline and blocky silhouette" |
| elif has("anime", "manga"): |
| tags.append("anime") |
| medium = "anime game illustration" |
| palette = "clean saturated colors with expressive accents" |
| texture = "smooth cel-shaded surfaces" |
| lighting = "bright rim light and glossy highlights" |
| linework = "confident manga-style outlines" |
| elif has("cyber", "neon", "synthwave"): |
| tags.append("vector") |
| tags.append("neon") |
| medium = "neon cyberpunk arcade game art" |
| palette = "electric cyan, magenta, violet, and black" |
| texture = "glowing edges, glossy panels, light bloom" |
| lighting = "high contrast neon rim lighting" |
| linework = "hard sci-fi outlines with luminous accents" |
| else: |
| tags.append("illustration") |
| medium = "cohesive 2D game illustration" |
| palette = "distinct theme-driven colors with clear value contrast" |
| texture = "clean readable game asset finish" |
| lighting = "balanced game lighting" |
| linework = "clear readable silhouette and controlled edges" |
|
|
| if has("top-down", "top down", "shooter", "rpg", "arena"): |
| camera = "top-down readable game camera" |
| elif has("platformer", "side-scroller", "side scroller"): |
| camera = "side-view platformer camera" |
| else: |
| camera = "game-ready camera angle matching the role" |
|
|
| return StylePlan(medium, palette, texture, lighting, linework, camera, tuple(tags)) |
|
|
|
|
| def build_asset_prompt(role: str, prompt: str, style_hint: str) -> str: |
| plan = interpret_style_hint(style_hint) |
| slug = slugify(role) |
| is_background = any(word in slug for word in ("background", "backdrop", "scene", "map", "level")) |
| if is_background: |
| asset_instruction = ( |
| "Create one complete 2D game background scene, not a texture tile, not a material sample, " |
| "not a UV map. Full scene composition for a canvas game. Empty environment only: no player, " |
| "no character, no creature, no vehicle, no mascot, no foreground subject." |
| ) |
| else: |
| asset_instruction = ( |
| "Create one complete standalone 2D sprite of the whole subject, centered, full body or full vehicle, " |
| "single object, transparent or plain background, readable silhouette. Not a texture map, not a tiled " |
| "pattern, not a material swatch, not a UV unwrap, not a 3D model skin." |
| ) |
| return ( |
| f"{role} asset: {prompt}. Style interpretation: {plan.medium}; {plan.palette}; " |
| f"{plan.texture}; {plan.lighting}; {plan.linework}; {plan.camera}. " |
| f"{asset_instruction} " |
| "Game asset, readable at small size, no text, no watermark." |
| ) |
|
|
|
|
| def parse_role_lines(raw_roles: str) -> list[tuple[str, str]]: |
| parsed: list[tuple[str, str]] = [] |
| 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 |
| parsed.append((role, prompt)) |
| return parsed |
|
|
|
|
| def infer_code_context(html_code: str) -> str: |
| text = html_code[:12000] |
| filenames = sorted(set(re.findall(r"['\"]([^'\"]+?\.(?:png|jpg|jpeg|webp|gif))['\"]", text, flags=re.I))) |
| canvas = re.findall(r"<canvas[^>]*?(?:width=['\"]?(\d+)|height=['\"]?(\d+))", text, flags=re.I) |
| controls = [] |
| lowered = text.lower() |
| for label, words in { |
| "top-down movement": ("arrowup", "arrowdown", "keys.has(\"w\")", "keys.has('w')"), |
| "platformer": ("gravity", "grounded", "platform"), |
| "shooting": ("bullet", "shoot", "projectile", "laser"), |
| "enemies": ("enemy", "monster", "spawn"), |
| }.items(): |
| if any(word in lowered for word in words): |
| controls.append(label) |
| return ( |
| f"Referenced asset filenames: {', '.join(filenames[:24]) or 'none found'}. " |
| f"Detected game mechanics: {', '.join(controls) or 'not obvious'}. " |
| f"Canvas hints found: {canvas[:4] or 'none'}." |
| ) |
|
|
|
|
| def local_prompt_map(role_lines: list[tuple[str, str]], style_hint: str) -> dict[str, str]: |
| return {role: build_asset_prompt(role, prompt, style_hint) for role, prompt in role_lines} |
|
|
|
|
| def extract_json_object(text: str) -> dict | None: |
| match = re.search(r"\{.*\}", text, flags=re.S) |
| if not match: |
| return None |
| try: |
| value = json.loads(match.group(0)) |
| except Exception: |
| return None |
| return value if isinstance(value, dict) else None |
|
|
|
|
| def short_error(exc: Exception) -> str: |
| if isinstance(exc, HTTPError): |
| body = exc.read().decode("utf-8", errors="replace")[:500] |
| return f"HTTP {exc.code}: {body}" |
| if isinstance(exc, URLError): |
| return f"URL error: {exc.reason}" |
| return f"{type(exc).__name__}: {str(exc)[:500]}" |
|
|
|
|
| def hf_prompt_json(html_code: str, role_lines: list[tuple[str, str]], style_hint: str) -> tuple[dict[str, str] | None, str | None]: |
| if not USE_HF_PROMPT_PROVIDER: |
| return None, None |
| if not HF_TOKEN: |
| return None, "HF_TOKEN is not visible to the Space runtime" |
|
|
| role_block = "\n".join(f"- {role}: {prompt}" for role, prompt in role_lines) |
| instruction = ( |
| "You are a senior game art director and prompt engineer. Read the HTML game context, " |
| "the requested asset roles, and the shared theme/style. Return ONLY a JSON object where " |
| "each key is the exact role name and each value is one concise text-to-image prompt. " |
| "Each prompt must specify: subject silhouette/shape, camera angle, art style, palette, " |
| "transparent background for sprites/items, full scene for backgrounds, no text, no watermark. " |
| "Make different roles visually distinct and suitable for embedding in an HTML game." |
| ) |
| user_text = ( |
| f"HTML/game context summary: {infer_code_context(html_code)}\n\n" |
| f"Shared theme/style: {style_hint}\n\n" |
| f"Asset roles:\n{role_block}\n\n" |
| "Return JSON only." |
| ) |
| payload = { |
| "model": HF_PROMPT_MODEL, |
| "messages": [ |
| {"role": "system", "content": instruction}, |
| {"role": "user", "content": user_text}, |
| ], |
| "max_tokens": 900, |
| "temperature": 0.55, |
| "top_p": 0.9, |
| "stream": False, |
| } |
| request = Request( |
| HF_PROMPT_ENDPOINT, |
| data=json.dumps(payload).encode("utf-8"), |
| headers={ |
| "Authorization": f"Bearer {HF_TOKEN}", |
| "Content-Type": "application/json", |
| }, |
| method="POST", |
| ) |
| try: |
| with urlopen(request, timeout=90) as response: |
| raw = response.read().decode("utf-8", errors="replace") |
| parsed = json.loads(raw) |
| if isinstance(parsed, dict) and parsed.get("choices"): |
| message = parsed["choices"][0].get("message", {}) |
| text = message.get("content", "") if isinstance(message, dict) else str(message) |
| elif isinstance(parsed, list) and parsed: |
| text = parsed[0].get("generated_text", "") if isinstance(parsed[0], dict) else str(parsed[0]) |
| elif isinstance(parsed, dict): |
| text = parsed.get("generated_text", parsed.get("text", "")) |
| else: |
| text = str(parsed) |
| obj = extract_json_object(text) |
| if not obj: |
| return None, f"{HF_PROMPT_MODEL} returned non-JSON text: {text[:300]}" |
| return { |
| role: str(obj.get(role, "")).strip() |
| for role, _ in role_lines |
| if str(obj.get(role, "")).strip() |
| }, None |
| except Exception as exc: |
| return None, short_error(exc) |
|
|
|
|
| def build_prompt_map(html_code: str, raw_roles: str, style_hint: str) -> tuple[list[tuple[str, str]], dict[str, str], str, str | None]: |
| role_lines = parse_role_lines(raw_roles) |
| local_map = local_prompt_map(role_lines, style_hint) |
| ai_map, prompt_error = hf_prompt_json(html_code, role_lines, style_hint) |
| if ai_map and all(role in ai_map for role, _ in role_lines): |
| return role_lines, ai_map, HF_PROMPT_MODEL, None |
| return role_lines, local_map, "local prompt interpreter", prompt_error |
|
|
|
|
| def parse_assets(raw_roles: str, style_hint: str, prompt_map: dict[str, str] | None = None) -> list[AssetSpec]: |
| specs: list[AssetSpec] = [] |
| for role, prompt in parse_role_lines(raw_roles): |
| 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 = (prompt_map or {}).get(role) or build_asset_prompt(role, prompt, style_hint) |
| specs.append(AssetSpec(role=role, prompt=full_prompt, filename=filename, width=width, height=height)) |
| return specs |
|
|
|
|
| def is_background_spec(spec: AssetSpec) -> bool: |
| slug = slugify(spec.role) |
| return spec.width > spec.height * 2 or any( |
| word in slug for word in ("background", "backdrop", "scene", "map", "level") |
| ) |
|
|
|
|
| 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 palette_for(text: str) -> list[tuple[int, int, int]]: |
| lowered = text.lower() |
| if any(word in lowered for word in ("watercolor", "ink wash", "gouache", "paper grain")): |
| return [(113, 151, 168), (207, 180, 158), (241, 232, 214), (154, 118, 148)] |
| if any(word in lowered for word in ("clay", "claymation", "stop-motion", "plasticine")): |
| return [(204, 105, 83), (238, 175, 104), (107, 72, 61), (242, 213, 151)] |
| if any(word in lowered for word in ("oil", "painterly", "impasto", "canvas")): |
| return [(98, 45, 122), (213, 154, 70), (38, 28, 42), (230, 212, 156)] |
| if any(word in lowered for word in ("vector", "flat", "logo")): |
| return [(42, 125, 246), (255, 81, 128), (18, 24, 38), (255, 221, 64)] |
| if any(word in lowered for word in ("anime", "manga")): |
| return [(78, 151, 236), (255, 139, 173), (42, 45, 76), (255, 238, 166)] |
| themed = [ |
| (("lava", "fire", "volcano", "hell"), [(139, 28, 18), (239, 84, 34), (42, 18, 16), (255, 198, 71)]), |
| (("ice", "snow", "frost", "arctic"), [(75, 151, 191), (187, 235, 255), (24, 63, 96), (240, 252, 255)]), |
| (("desert", "sand", "dune", "western"), [(194, 139, 72), (236, 198, 112), (102, 66, 36), (255, 226, 151)]), |
| (("ocean", "sea", "water", "underwater"), [(25, 103, 155), (57, 191, 205), (8, 43, 85), (177, 239, 224)]), |
| (("forest", "garden", "grass", "jungle"), [(51, 132, 72), (126, 196, 81), (24, 72, 43), (242, 201, 84)]), |
| (("dungeon", "castle", "stone", "crypt"), [(78, 82, 96), (133, 140, 154), (34, 36, 48), (181, 154, 94)]), |
| (("city", "street", "cyber", "neon"), [(40, 52, 87), (39, 230, 226), (19, 21, 38), (239, 67, 154)]), |
| (("sci-fi", "scifi", "space", "arena", "metal", "shooter"), [(30, 216, 236), (235, 64, 96), (18, 22, 31), (245, 192, 51)]), |
| ] |
| for words, colors in themed: |
| if any(word in lowered for word in words): |
| return colors |
|
|
| digest = hashlib.sha256(text.encode("utf-8")).digest() |
| base = digest[0] % 360 |
|
|
| def hsl_to_rgb(h: float, s: float, l: float) -> tuple[int, int, int]: |
| c = (1 - abs(2 * l - 1)) * s |
| x = c * (1 - abs((h / 60) % 2 - 1)) |
| m = l - c / 2 |
| if h < 60: |
| r, g, b = c, x, 0 |
| elif h < 120: |
| r, g, b = x, c, 0 |
| elif h < 180: |
| r, g, b = 0, c, x |
| elif h < 240: |
| r, g, b = 0, x, c |
| elif h < 300: |
| r, g, b = x, 0, c |
| else: |
| r, g, b = c, 0, x |
| return (int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)) |
|
|
| return [ |
| hsl_to_rgb((base + offset) % 360, 0.68, lightness) |
| for offset, lightness in ((0, 0.46), (45, 0.56), (145, 0.38), (220, 0.64)) |
| ] |
|
|
|
|
| def style_tags_for(prompt: str) -> set[str]: |
| lowered = prompt.lower() |
| tags = set() |
| groups = { |
| "watercolor": ("watercolor", "ink wash", "gouache", "paper grain"), |
| "vector": ("vector", "flat", "logo", "hard-edged", "graphic"), |
| "clay": ("clay", "claymation", "stop-motion", "plasticine", "sculpted"), |
| "oil": ("oil", "painterly", "impasto", "canvas", "bristle"), |
| "pixel": ("pixel", "8-bit", "16-bit", "retro"), |
| "anime": ("anime", "manga", "cel-shaded"), |
| "neon": ("neon", "cyberpunk", "synthwave", "glowing"), |
| } |
| for tag, words in groups.items(): |
| if any(word in lowered for word in words): |
| tags.add(tag) |
| return tags |
|
|
|
|
| def apply_style_finish(content: bytes, spec: AssetSpec, rng: random.Random) -> bytes: |
| tags = style_tags_for(spec.prompt) |
| if not tags: |
| return content |
|
|
| image = Image.open(io.BytesIO(content)).convert("RGBA") |
| draw = ImageDraw.Draw(image, "RGBA") |
| is_bg = is_background_spec(spec) |
|
|
| if "watercolor" in tags: |
| wash = Image.new("RGBA", image.size, (245, 238, 220, 34 if is_bg else 12)) |
| image = Image.alpha_composite(image, wash) |
| draw = ImageDraw.Draw(image, "RGBA") |
| for _ in range(60 if is_bg else 18): |
| x = rng.randint(-20, image.width) |
| y = rng.randint(-20, image.height) |
| r = rng.randint(10, 70 if is_bg else 24) |
| color = rng.choice(palette_for(spec.prompt)) + (rng.randint(18, 55),) |
| draw.ellipse((x - r, y - r, x + r, y + r), fill=color) |
| image = image.filter(ImageFilter.GaussianBlur(0.45)) |
|
|
| if "oil" in tags: |
| for _ in range(120 if is_bg else 34): |
| x = rng.randint(0, image.width) |
| y = rng.randint(0, image.height) |
| length = rng.randint(8, 38 if is_bg else 16) |
| color = rng.choice(palette_for(spec.prompt)) + (rng.randint(75, 145),) |
| draw.line((x, y, x + rng.randint(-length, length), y + rng.randint(-length, length)), fill=color, width=rng.randint(2, 5)) |
| image = ImageEnhance.Contrast(image).enhance(1.12) |
|
|
| if "clay" in tags: |
| image = ImageEnhance.Color(image).enhance(0.82) |
| image = ImageEnhance.Contrast(image).enhance(1.18) |
| draw = ImageDraw.Draw(image, "RGBA") |
| for _ in range(45 if is_bg else 14): |
| x = rng.randint(0, image.width) |
| y = rng.randint(0, image.height) |
| r = rng.randint(3, 18 if is_bg else 8) |
| draw.ellipse((x - r, y - r, x + r, y + r), outline=(255, 238, 205, rng.randint(25, 70)), width=2) |
| image = image.filter(ImageFilter.SMOOTH_MORE) |
|
|
| if "vector" in tags: |
| image = ImageEnhance.Contrast(image).enhance(1.35) |
| image = ImageEnhance.Color(image).enhance(1.25) |
| if not is_bg: |
| alpha = image.getchannel("A") |
| outline = alpha.filter(ImageFilter.MaxFilter(7)).filter(ImageFilter.GaussianBlur(0.2)) |
| outlined = Image.new("RGBA", image.size, (12, 16, 28, 255)) |
| outlined.putalpha(outline) |
| image = Image.alpha_composite(outlined, image) |
|
|
| if "anime" in tags: |
| image = ImageEnhance.Color(image).enhance(1.35) |
| image = ImageEnhance.Contrast(image).enhance(1.18) |
| draw = ImageDraw.Draw(image, "RGBA") |
| for _ in range(8 if is_bg else 4): |
| x = rng.randint(0, image.width) |
| y = rng.randint(0, image.height) |
| draw.line((x, y, x + rng.randint(20, 80), y - rng.randint(10, 50)), fill=(255, 255, 255, 80), width=2) |
|
|
| if "neon" in tags: |
| glow = image.filter(ImageFilter.GaussianBlur(4 if is_bg else 3)) |
| image = Image.blend(glow, image, 0.72) |
| image = ImageEnhance.Color(image).enhance(1.45) |
|
|
| if "pixel" in tags: |
| small = image.resize((max(1, image.width // 4), max(1, image.height // 4)), Image.Resampling.NEAREST) |
| image = small.resize(image.size, Image.Resampling.NEAREST) |
|
|
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
|
|
| def draw_background(spec: AssetSpec, rng: random.Random) -> bytes: |
| colors = palette_for(spec.prompt) |
| image = Image.new("RGBA", (spec.width, spec.height), colors[2] + (255,)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| prompt = spec.prompt.lower() |
|
|
| if any(word in prompt for word in ("sci-fi", "scifi", "arena", "metal", "hazard", "grid", "shooter")): |
| image = Image.new("RGBA", (spec.width, spec.height), (18, 22, 31, 255)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| panel = 64 |
| for y in range(0, spec.height, panel): |
| for x in range(0, spec.width, panel): |
| shade = 24 + ((x // panel + y // panel) % 2) * 12 |
| draw.rectangle( |
| (x, y, x + panel - 2, y + panel - 2), |
| fill=(shade, shade + 4, shade + 14, 255), |
| outline=(58, 74, 92, 170), |
| ) |
| for x in range(0, spec.width, 96): |
| draw.line((x, 0, x, spec.height), fill=(34, 215, 236, 95), width=2) |
| for y in range(0, spec.height, 96): |
| draw.line((0, y, spec.width, y), fill=(34, 215, 236, 95), width=2) |
| for _ in range(8): |
| x = rng.randint(0, spec.width - 120) |
| y = rng.randint(0, spec.height - 34) |
| draw.rectangle((x, y, x + 120, y + 34), fill=(36, 42, 53, 235), outline=(110, 125, 142, 210)) |
| for stripe in range(0, 120, 24): |
| draw.polygon( |
| [(x + stripe, y), (x + stripe + 12, y), (x + stripe - 12, y + 34), (x + stripe - 24, y + 34)], |
| fill=(245, 192, 51, 210), |
| ) |
| for _ in range(18): |
| x = rng.randint(0, spec.width) |
| y = rng.randint(0, spec.height) |
| r = rng.randint(10, 32) |
| draw.ellipse((x - r, y - r, x + r, y + r), outline=(224, 54, 85, 100), width=2) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in prompt for word in ("space", "star", "nebula", "galaxy", "cosmic")): |
| image = Image.new("RGBA", (spec.width, spec.height), (3, 4, 18, 255)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| for y in range(spec.height): |
| mix = y / max(1, spec.height - 1) |
| color = ( |
| int(4 + 24 * mix), |
| int(6 + 10 * mix), |
| int(24 + 45 * mix), |
| 255, |
| ) |
| draw.line((0, y, spec.width, y), fill=color) |
| for _ in range(7): |
| x = rng.randint(-120, spec.width) |
| y = rng.randint(-90, spec.height) |
| w = rng.randint(180, 420) |
| h = rng.randint(90, 230) |
| fill = rng.choice(colors) + (rng.randint(32, 70),) |
| draw.ellipse((x, y, x + w, y + h), fill=fill) |
| for _ in range(140): |
| x = rng.randint(0, spec.width - 1) |
| y = rng.randint(0, spec.height - 1) |
| size = rng.choice((1, 1, 2, 3)) |
| shade = rng.randint(180, 255) |
| draw.rectangle((x, y, x + size, y + size), fill=(shade, shade, 255, rng.randint(130, 240))) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in prompt for word in ("garden", "flower", "grass", "meadow", "forest", "path")): |
| sky = (184, 224, 239) |
| grass = (80, 151, 82) |
| image = Image.new("RGBA", (spec.width, spec.height), sky + (255,)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| horizon = int(spec.height * 0.56) |
| for y in range(horizon): |
| mix = y / max(1, horizon - 1) |
| color = ( |
| int(230 * (1 - mix) + sky[0] * mix), |
| int(246 * (1 - mix) + sky[1] * mix), |
| int(255 * (1 - mix) + sky[2] * mix), |
| 255, |
| ) |
| draw.line((0, y, spec.width, y), fill=color) |
| draw.rectangle((0, horizon, spec.width, spec.height), fill=grass + (255,)) |
| path_color = (198, 169, 112, 255) |
| draw.polygon( |
| [ |
| (int(spec.width * 0.39), horizon), |
| (int(spec.width * 0.61), horizon), |
| (int(spec.width * 0.83), spec.height), |
| (int(spec.width * 0.18), spec.height), |
| ], |
| fill=path_color, |
| ) |
| for _ in range(18): |
| x = rng.randint(0, spec.width) |
| y = rng.randint(horizon + 48, spec.height - 18) |
| stem = rng.randint(5, 12) |
| draw.line((x, y + stem, x, y), fill=(43, 109, 53, 210), width=2) |
| petal = rng.choice(colors) + (230,) |
| draw.ellipse((x - 4, y - 3, x + 4, y + 5), fill=petal) |
| for _ in range(10): |
| x = rng.randint(-40, spec.width) |
| y = rng.randint(horizon - 35, horizon + 20) |
| draw.ellipse((x, y, x + rng.randint(65, 140), y + rng.randint(36, 72)), fill=(42, 121, 64, 185)) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in prompt for word in ("lava", "fire", "volcano")): |
| image = Image.new("RGBA", (spec.width, spec.height), (52, 23, 18, 255)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| for y in range(0, spec.height, 58): |
| draw.rectangle((0, y, spec.width, y + 30), fill=(70, 32, 25, 255)) |
| for _ in range(18): |
| x = rng.randint(-80, spec.width) |
| y = rng.randint(0, spec.height) |
| draw.line((x, y, x + rng.randint(120, 320), y + rng.randint(-20, 20)), fill=(255, 111, 43, 220), width=rng.randint(5, 12)) |
| for _ in range(45): |
| x = rng.randint(0, spec.width) |
| y = rng.randint(0, spec.height) |
| r = rng.randint(5, 20) |
| draw.ellipse((x - r, y - r, x + r, y + r), fill=(255, 192, 53, rng.randint(70, 140))) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in prompt for word in ("dungeon", "castle", "stone", "crypt")): |
| image = Image.new("RGBA", (spec.width, spec.height), (48, 50, 62, 255)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| tile = 54 |
| for y in range(0, spec.height, tile): |
| for x in range(0, spec.width, tile): |
| shade = rng.randint(48, 78) |
| draw.rectangle((x, y, x + tile - 2, y + tile - 2), fill=(shade, shade + 2, shade + 8, 255), outline=(27, 29, 38, 190)) |
| for _ in range(22): |
| x = rng.randint(0, spec.width) |
| y = rng.randint(0, spec.height) |
| draw.ellipse((x - 8, y - 8, x + 8, y + 8), fill=(185, 146, 80, 110)) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in prompt for word in ("ocean", "sea", "water", "underwater")): |
| image = Image.new("RGBA", (spec.width, spec.height), (8, 54, 92, 255)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| for y in range(spec.height): |
| mix = y / max(1, spec.height - 1) |
| draw.line((0, y, spec.width, y), fill=(int(16 + 6 * mix), int(99 + 62 * mix), int(151 + 58 * mix), 255)) |
| for _ in range(26): |
| x = rng.randint(-100, spec.width) |
| y = rng.randint(0, spec.height) |
| draw.arc((x, y, x + 180, y + 70), 0, 180, fill=(165, 232, 225, 90), width=3) |
| for _ in range(38): |
| x = rng.randint(0, spec.width) |
| y = rng.randint(0, spec.height) |
| r = rng.randint(2, 8) |
| draw.ellipse((x - r, y - r, x + r, y + r), outline=(205, 247, 245, 120), width=2) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| for y in range(spec.height): |
| mix = y / max(1, spec.height - 1) |
| color = tuple(int(colors[2][i] * (1 - mix) + colors[0][i] * mix) for i in range(3)) |
| draw.line((0, y, spec.width, y), fill=color + (255,)) |
|
|
| for _ in range(34): |
| x = rng.randint(-80, spec.width) |
| y = rng.randint(-40, spec.height) |
| w = rng.randint(80, 260) |
| h = rng.randint(36, 150) |
| fill = rng.choice(colors) + (rng.randint(45, 110),) |
| draw.ellipse((x, y, x + w, y + h), fill=fill) |
|
|
| for _ in range(80): |
| x = rng.randint(0, spec.width) |
| y = rng.randint(0, spec.height) |
| size = rng.randint(2, 7) |
| fill = rng.choice(colors) + (rng.randint(90, 190),) |
| draw.rectangle((x, y, x + size, y + size), fill=fill) |
|
|
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
|
|
| def draw_sprite(spec: AssetSpec, rng: random.Random) -> bytes: |
| colors = palette_for(f"{spec.role} {spec.prompt}") |
| image = Image.new("RGBA", (spec.width, spec.height), (0, 0, 0, 0)) |
| draw = ImageDraw.Draw(image, "RGBA") |
| cx, cy = spec.width // 2, spec.height // 2 |
| scale = min(spec.width, spec.height) / 128 |
| role = slugify(spec.role) |
| role_prompt = f"{spec.role} {spec.prompt}".lower() |
| outline = (18, 24, 31, 255) |
| body = colors[0] + (255,) |
| accent = colors[1] + (255,) |
| trim = colors[3] + (255,) |
|
|
| if any(word in role_prompt for word in ("car", "racing", "racer", "buggy", "vehicle", "truck")): |
| shadow = (int(cx - 43 * scale), int(cy + 28 * scale), int(cx + 43 * scale), int(cy + 42 * scale)) |
| draw.ellipse(shadow, fill=(0, 0, 0, 58)) |
| draw.rounded_rectangle( |
| (cx - 34 * scale, cy - 42 * scale, cx + 34 * scale, cy + 42 * scale), |
| radius=int(18 * scale), |
| fill=outline, |
| ) |
| draw.rounded_rectangle( |
| (cx - 26 * scale, cy - 34 * scale, cx + 26 * scale, cy + 34 * scale), |
| radius=int(13 * scale), |
| fill=body, |
| ) |
| draw.rectangle((cx - 15 * scale, cy - 24 * scale, cx + 15 * scale, cy - 5 * scale), fill=accent) |
| draw.rectangle((cx - 18 * scale, cy + 8 * scale, cx + 18 * scale, cy + 27 * scale), fill=trim) |
| for side in (-1, 1): |
| draw.rounded_rectangle( |
| (cx + side * 27 * scale - 9 * scale, cy - 30 * scale, cx + side * 27 * scale + 9 * scale, cy - 13 * scale), |
| radius=int(5 * scale), |
| fill=(20, 22, 27, 255), |
| ) |
| draw.rounded_rectangle( |
| (cx + side * 27 * scale - 9 * scale, cy + 15 * scale, cx + side * 27 * scale + 9 * scale, cy + 32 * scale), |
| radius=int(5 * scale), |
| fill=(20, 22, 27, 255), |
| ) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in role_prompt for word in ("turret", "tower", "cannon", "launcher")): |
| draw.ellipse((cx - 38 * scale, cy + 22 * scale, cx + 38 * scale, cy + 45 * scale), fill=(0, 0, 0, 60)) |
| draw.ellipse((cx - 36 * scale, cy - 12 * scale, cx + 36 * scale, cy + 42 * scale), fill=outline) |
| draw.ellipse((cx - 28 * scale, cy - 5 * scale, cx + 28 * scale, cy + 34 * scale), fill=body) |
| barrel_angle = rng.choice((-0.45, -0.2, 0, 0.2, 0.45)) |
| length = 52 * scale |
| bx = cx + math.sin(barrel_angle) * length |
| by = cy - 38 * scale |
| draw.line((cx, cy - 5 * scale, bx, by), fill=outline, width=max(10, int(17 * scale))) |
| draw.line((cx, cy - 5 * scale, bx, by), fill=trim, width=max(5, int(9 * scale))) |
| draw.rectangle((cx - 19 * scale, cy + 15 * scale, cx + 19 * scale, cy + 39 * scale), fill=accent) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if role in ("coin", "gem", "key", "orb", "pickup", "collectible") or any( |
| word in role_prompt for word in ("coin", "gem", "key", "pickup", "collectible", "powerup", "power-up", "treasure") |
| ): |
| draw.ellipse((cx - 33 * scale, cy - 33 * scale, cx + 33 * scale, cy + 33 * scale), fill=outline) |
| if any(word in role_prompt for word in ("gem", "crystal", "diamond")): |
| points = [ |
| (cx, cy - 43 * scale), |
| (cx + 36 * scale, cy - 8 * scale), |
| (cx + 20 * scale, cy + 40 * scale), |
| (cx - 20 * scale, cy + 40 * scale), |
| (cx - 36 * scale, cy - 8 * scale), |
| ] |
| draw.polygon(points, fill=body) |
| draw.polygon([(cx, cy - 34 * scale), (cx + 18 * scale, cy - 4 * scale), (cx, cy + 28 * scale), (cx - 18 * scale, cy - 4 * scale)], fill=accent) |
| elif "key" in role_prompt: |
| draw.ellipse((cx - 30 * scale, cy - 20 * scale, cx + 8 * scale, cy + 18 * scale), fill=body) |
| draw.ellipse((cx - 17 * scale, cy - 8 * scale, cx - 3 * scale, cy + 6 * scale), fill=(0, 0, 0, 0)) |
| draw.rounded_rectangle((cx + 2 * scale, cy - 5 * scale, cx + 43 * scale, cy + 7 * scale), radius=int(5 * scale), fill=accent) |
| draw.rectangle((cx + 30 * scale, cy + 4 * scale, cx + 43 * scale, cy + 17 * scale), fill=trim) |
| else: |
| draw.ellipse((cx - 27 * scale, cy - 27 * scale, cx + 27 * scale, cy + 27 * scale), fill=body) |
| draw.ellipse((cx - 17 * scale, cy - 17 * scale, cx + 17 * scale, cy + 17 * scale), outline=trim, width=max(3, int(7 * scale))) |
| draw.arc((cx - 12 * scale, cy - 20 * scale, cx + 20 * scale, cy + 13 * scale), 195, 300, fill=(255, 255, 255, 180), width=max(2, int(4 * scale))) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in role_prompt for word in ("slime", "blob", "gel", "jelly")): |
| draw.ellipse((cx - 40 * scale, cy + 24 * scale, cx + 40 * scale, cy + 42 * scale), fill=(0, 0, 0, 55)) |
| draw.pieslice((cx - 40 * scale, cy - 28 * scale, cx + 40 * scale, cy + 52 * scale), 180, 360, fill=outline) |
| draw.pieslice((cx - 32 * scale, cy - 20 * scale, cx + 32 * scale, cy + 42 * scale), 180, 360, fill=body) |
| for side in (-1, 1): |
| draw.ellipse((cx + side * 11 * scale - 5 * scale, cy + 3 * scale, cx + side * 11 * scale + 5 * scale, cy + 13 * scale), fill=(255, 255, 255, 230)) |
| draw.arc((cx - 12 * scale, cy + 12 * scale, cx + 12 * scale, cy + 27 * scale), 10, 170, fill=trim, width=max(2, int(4 * scale))) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if any(word in role_prompt for word in ("beetle", "bug", "spider", "insect", "crawler", "creep")): |
| draw.ellipse((cx - 28 * scale, cy - 34 * scale, cx + 28 * scale, cy + 34 * scale), fill=outline) |
| draw.ellipse((cx - 21 * scale, cy - 27 * scale, cx + 21 * scale, cy + 29 * scale), fill=body) |
| draw.line((cx, cy - 25 * scale, cx, cy + 30 * scale), fill=trim, width=max(2, int(4 * scale))) |
| for y in (-18, 0, 18): |
| for side in (-1, 1): |
| draw.line((cx + side * 18 * scale, cy + y * scale, cx + side * 45 * scale, cy + (y + rng.choice((-10, 10))) * scale), fill=outline, width=max(3, int(5 * scale))) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if role in ("bullet", "projectile", "laser", "shot") or any(word in role_prompt for word in ("bullet", "projectile", "laser", "energy")): |
| glow = colors[1] + (85,) |
| core_width = rng.randint(7, 13) * scale |
| core_height = rng.randint(30, 44) * scale |
| glow_width = core_width + rng.randint(18, 30) * scale |
| glow_height = core_height + rng.randint(12, 25) * scale |
| draw.ellipse((cx - glow_width, cy - glow_height, cx + glow_width, cy + glow_height), fill=glow) |
| draw.rounded_rectangle( |
| (cx - core_width, cy - core_height, cx + core_width, cy + core_height), |
| radius=int(9 * scale), |
| fill=outline, |
| ) |
| draw.rounded_rectangle( |
| (cx - max(2, core_width - 4 * scale), cy - max(8, core_height - 6 * scale), cx + max(2, core_width - 4 * scale), cy + max(8, core_height - 6 * scale)), |
| radius=int(5 * scale), |
| fill=accent, |
| ) |
| for _ in range(5): |
| px = cx + rng.randint(-24, 24) * scale |
| py = cy + rng.randint(-38, 38) * scale |
| draw.line( |
| (px, py, px + rng.randint(-10, 10) * scale, py + rng.randint(-8, 8) * scale), |
| fill=trim, |
| width=max(1, int(2 * scale)), |
| ) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if role in ("enemy", "monster", "alien", "drone", "foe", "boss") or any(word in role_prompt for word in ("enemy", "drone", "alien", "monster")): |
| draw.ellipse((cx - 38 * scale, cy - 28 * scale, cx + 38 * scale, cy + 28 * scale), fill=outline) |
| draw.ellipse((cx - 32 * scale, cy - 22 * scale, cx + 32 * scale, cy + 22 * scale), fill=body) |
| draw.ellipse((cx - 16 * scale, cy - 16 * scale, cx + 16 * scale, cy + 16 * scale), fill=accent) |
| for side in (-1, 1): |
| draw.polygon( |
| [ |
| (cx + side * 28 * scale, cy - 8 * scale), |
| (cx + side * 56 * scale, cy - 28 * scale), |
| (cx + side * 48 * scale, cy + 20 * scale), |
| ], |
| fill=outline, |
| ) |
| draw.line((cx + side * 14 * scale, cy, cx + side * 58 * scale, cy), fill=trim, width=max(2, int(5 * scale))) |
| for _ in range(5): |
| angle = rng.random() * 6.283 |
| r = rng.randint(int(12 * scale), int(30 * scale)) |
| px = cx + int(r * math.cos(angle)) |
| py = cy + int(r * math.sin(angle)) |
| draw.ellipse((px - 3 * scale, py - 3 * scale, px + 3 * scale, py + 3 * scale), fill=trim) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| if role in ("player", "hero", "ship", "tank", "avatar") or any(word in role_prompt for word in ("shooter", "ship", "tank", "hero", "player", "armored")): |
| shadow = (int(cx - 36 * scale), int(cy + 34 * scale), int(cx + 36 * scale), int(cy + 48 * scale)) |
| draw.ellipse(shadow, fill=(0, 0, 0, 55)) |
| wing = rng.randint(28, 39) |
| nose = rng.randint(45, 56) |
| draw.polygon( |
| [ |
| (cx, cy - nose * scale), |
| (cx - wing * scale, cy + 34 * scale), |
| (cx, cy + 20 * scale), |
| (cx + wing * scale, cy + 34 * scale), |
| ], |
| fill=outline, |
| ) |
| draw.polygon( |
| [ |
| (cx, cy - (nose - 8) * scale), |
| (cx - (wing - 10) * scale, cy + 23 * scale), |
| (cx, cy + 12 * scale), |
| (cx + (wing - 10) * scale, cy + 23 * scale), |
| ], |
| fill=body, |
| ) |
| draw.ellipse((cx - 11 * scale, cy - 14 * scale, cx + 11 * scale, cy + 10 * scale), fill=accent) |
| draw.line((cx, cy - 42 * scale, cx, cy - 62 * scale), fill=trim, width=max(3, int(6 * scale))) |
| for side in (-1, 1): |
| x1 = cx + side * rng.randint(14, 23) * scale |
| x2 = cx + side * rng.randint(24, 32) * scale |
| y1 = cy + rng.randint(8, 18) * scale |
| y2 = cy + rng.randint(20, 30) * scale |
| draw.rectangle((min(x1, x2), y1, max(x1, x2), y2), fill=trim) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
| shadow = (int(cx - 36 * scale), int(cy + 34 * scale), int(cx + 36 * scale), int(cy + 48 * scale)) |
| draw.ellipse(shadow, fill=(0, 0, 0, 65)) |
|
|
| body_box = (cx - 26 * scale, cy - 18 * scale, cx + 26 * scale, cy + 34 * scale) |
| head_box = (cx - 20 * scale, cy - 52 * scale, cx + 20 * scale, cy - 12 * scale) |
| draw.rounded_rectangle(body_box, radius=int(12 * scale), fill=outline) |
| draw.rounded_rectangle( |
| tuple(v + d * scale for v, d in zip(body_box, (4, 4, -4, -4))), |
| radius=int(9 * scale), |
| fill=body, |
| ) |
| draw.ellipse(head_box, fill=outline) |
| draw.ellipse(tuple(v + d * scale for v, d in zip(head_box, (4, 4, -4, -4))), fill=accent) |
|
|
| eye_y = cy - int(33 * scale) |
| eye_dx = int(8 * scale) |
| eye_size = max(2, int(4 * scale)) |
| draw.ellipse((cx - eye_dx - eye_size, eye_y, cx - eye_dx + eye_size, eye_y + eye_size * 2), fill=(255, 255, 255, 245)) |
| draw.ellipse((cx + eye_dx - eye_size, eye_y, cx + eye_dx + eye_size, eye_y + eye_size * 2), fill=(255, 255, 255, 245)) |
|
|
| for side in (-1, 1): |
| arm = ( |
| cx + side * 22 * scale, |
| cy - 8 * scale, |
| cx + side * rng.randint(38, 48) * scale, |
| cy + rng.randint(8, 22) * scale, |
| ) |
| draw.line(arm, fill=outline, width=max(4, int(9 * scale))) |
| draw.line(arm, fill=trim, width=max(2, int(5 * scale))) |
| leg = ( |
| cx + side * 13 * scale, |
| cy + 30 * scale, |
| cx + side * rng.randint(16, 28) * scale, |
| cy + 53 * scale, |
| ) |
| draw.line(leg, fill=outline, width=max(4, int(10 * scale))) |
| draw.line(leg, fill=body, width=max(2, int(6 * scale))) |
|
|
| for _ in range(10): |
| px = rng.randint(int(cx - 22 * scale), int(cx + 22 * scale)) |
| py = rng.randint(int(cy - 10 * scale), int(cy + 26 * scale)) |
| draw.rectangle((px, py, px + max(2, int(4 * scale)), py + max(2, int(4 * scale))), fill=trim) |
|
|
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
|
|
| def local_asset_png(spec: AssetSpec, index: int, run_id: int) -> bytes: |
| seed_text = f"{spec.role}|{spec.prompt}|{index}|{run_id}" |
| rng = random.Random(seed_text) |
| if is_background_spec(spec): |
| content = draw_background(spec, rng) |
| else: |
| content = draw_sprite(spec, rng) |
| return apply_style_finish(content, spec, rng) |
|
|
|
|
| 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 diffusion_dimensions(spec: AssetSpec) -> tuple[int, int]: |
| if is_background_spec(spec): |
| return 384, 216 |
| return 256, 256 |
|
|
|
|
| def polish_diffusion_asset(image: Image.Image, spec: AssetSpec) -> bytes: |
| image = image.convert("RGBA") |
| if not is_background_spec(spec): |
| |
| |
| small = image.resize((128, 128), Image.LANCZOS) |
| corners = [ |
| small.getpixel((0, 0)), |
| small.getpixel((127, 0)), |
| small.getpixel((0, 127)), |
| small.getpixel((127, 127)), |
| ] |
| bg = tuple(sum(pixel[i] for pixel in corners) // len(corners) for i in range(3)) |
| pixels = small.load() |
| for y in range(small.height): |
| for x in range(small.width): |
| r, g, b, a = pixels[x, y] |
| dist = abs(r - bg[0]) + abs(g - bg[1]) + abs(b - bg[2]) |
| edge = min(x, y, small.width - 1 - x, small.height - 1 - y) |
| if dist < 64 or edge < 3: |
| a = 0 |
| elif dist < 125: |
| a = max(0, min(a, (dist - 64) * 4)) |
| pixels[x, y] = (r, g, b, a) |
| image = small.resize((spec.width, spec.height), Image.LANCZOS) |
| else: |
| image = image.resize((spec.width, spec.height), Image.LANCZOS) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return out.getvalue() |
|
|
|
|
| def free_diffusion_png(spec: AssetSpec, index: int, run_id: int) -> tuple[bytes | None, str | None]: |
| global FREE_DIFFUSION_PIPE, FREE_DIFFUSION_ERROR |
| if FREE_DIFFUSION_ERROR: |
| return None, FREE_DIFFUSION_ERROR |
| try: |
| import torch |
| from diffusers import DiffusionPipeline |
|
|
| if FREE_DIFFUSION_PIPE is None: |
| FREE_DIFFUSION_PIPE = DiffusionPipeline.from_pretrained(FREE_IMAGE_MODEL, torch_dtype=torch.float32) |
| FREE_DIFFUSION_PIPE = FREE_DIFFUSION_PIPE.to("cpu") |
| for component_name in ("unet", "vae", "text_encoder"): |
| component = getattr(FREE_DIFFUSION_PIPE, component_name, None) |
| if component is not None and hasattr(component, "to"): |
| component.to(device="cpu", dtype=torch.float32) |
| if hasattr(FREE_DIFFUSION_PIPE, "enable_attention_slicing"): |
| FREE_DIFFUSION_PIPE.enable_attention_slicing() |
|
|
| width, height = diffusion_dimensions(spec) |
| seed = abs(hash(f"{spec.role}|{spec.prompt}|{index}|{run_id}")) % 2147483647 |
| generator = torch.Generator(device="cpu").manual_seed(seed) |
| negative_prompt = ( |
| "texture map, material texture, seamless texture, tiled pattern, uv map, uv unwrap, " |
| "skin texture, 3d model texture, normal map, roughness map, diffuse map, sprite sheet, " |
| "atlas, multiple objects, cropped subject, close-up surface, fabric swatch, text, watermark, " |
| "person, character, player, hero, creature, monster, vehicle, mascot, foreground subject" |
| ) |
| image = FREE_DIFFUSION_PIPE( |
| spec.prompt, |
| negative_prompt=negative_prompt, |
| width=width, |
| height=height, |
| num_inference_steps=FREE_IMAGE_STEPS, |
| guidance_scale=6.0, |
| generator=generator, |
| ).images[0] |
| return polish_diffusion_asset(image, spec), None |
| except Exception as exc: |
| FREE_DIFFUSION_ERROR = short_error(exc) |
| return None, FREE_DIFFUSION_ERROR |
|
|
|
|
| def hf_image_png(spec: AssetSpec, index: int, run_id: int) -> tuple[bytes | None, str | None]: |
| if not USE_HF_IMAGE_PROVIDER: |
| return None, None |
| if not HF_TOKEN: |
| return None, "HF_TOKEN is not visible to the Space runtime" |
| if InferenceClient is None: |
| return None, "huggingface_hub is not installed in this Space" |
|
|
| try: |
| client = InferenceClient(api_key=HF_TOKEN) |
| image = client.text_to_image( |
| prompt=spec.prompt, |
| model=HF_IMAGE_MODEL, |
| width=spec.width, |
| height=spec.height, |
| num_inference_steps=4, |
| guidance_scale=0.0, |
| seed=abs(hash(f"{spec.role}|{spec.prompt}|{index}|{run_id}")) % 2147483647, |
| ) |
| out = io.BytesIO() |
| image.save(out, format="PNG") |
| return image_to_png_bytes(out.getvalue(), spec.width, spec.height), None |
| except Exception as exc: |
| return None, short_error(exc) |
|
|
|
|
| def generate_asset(spec: AssetSpec, index: int, run_id: int) -> tuple[str, str, str | None, str]: |
| if not is_background_spec(spec) and not USE_DIFFUSION_FOR_SPRITES: |
| png_content = local_asset_png(spec, index, run_id) |
| return ( |
| png_bytes_to_data_uri(png_content), |
| write_gallery_image(png_content, spec.role), |
| None, |
| "shape-aware 2D sprite generator", |
| ) |
| if is_background_spec(spec) and not USE_DIFFUSION_FOR_BACKGROUNDS: |
| png_content = local_asset_png(spec, index, run_id) |
| return ( |
| png_bytes_to_data_uri(png_content), |
| write_gallery_image(png_content, spec.role), |
| None, |
| "scene-safe 2D background generator", |
| ) |
|
|
| png_content, free_error = free_diffusion_png(spec, index, run_id) |
| source = FREE_IMAGE_MODEL |
| error = free_error |
| if png_content is None: |
| png_content, hf_error = hf_image_png(spec, index, run_id) |
| source = HF_IMAGE_MODEL |
| error = hf_error or free_error |
| if png_content is None: |
| png_content = local_asset_png(spec, index, run_id) |
| source = "local procedural fallback" |
| return ( |
| png_bytes_to_data_uri(png_content), |
| write_gallery_image(png_content, spec.role), |
| error if source == "local procedural fallback" else None, |
| source, |
| ) |
|
|
|
|
| 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"}) |
| if slug == "bullet": |
| names.update({"bullet.png", "projectile.png", "laser.png", "shot.png", "sprite_bullet.jpg"}) |
| return names |
|
|
|
|
| def asset_aliases(spec: AssetSpec) -> list[str]: |
| slug = slugify(spec.role) |
| aliases = {slug, spec.role.strip().lower(), spec.filename.lower()} |
| prompt = spec.prompt.lower() |
| role_groups = { |
| "player": ("player", "hero", "character", "avatar", "ship", "shooter", "tank", "knight", "wizard"), |
| "enemy": ("enemy", "monster", "alien", "drone", "foe", "zombie", "boss", "hazard"), |
| "bullet": ("bullet", "projectile", "laser", "shot", "missile", "beam", "ammo"), |
| "coin": ("coin", "gem", "seed", "star", "pickup", "collectible", "key", "orb"), |
| "background": ("background", "backdrop", "bg", "map", "level", "arena", "scene", "world", "floor"), |
| } |
| for group, words in role_groups.items(): |
| if group in slug or any(word in prompt for word in words): |
| aliases.update(words) |
| return sorted(alias for alias in aliases if alias) |
|
|
|
|
| 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"] |
| background_uri = None |
| asset_map: dict[str, str] = {} |
| alias_map: dict[str, list[str]] = {} |
|
|
| for spec in specs: |
| data_uri = assets[spec.role] |
| slug = slugify(spec.role) |
| asset_map[slug] = data_uri |
| alias_map[slug] = asset_aliases(spec) |
| manifest_lines.append(f"{spec.role}: {spec.filename}") |
| if is_background_spec(spec) and background_uri is None: |
| background_uri = data_uri |
| 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_json = json.dumps(asset_map) |
| alias_json = json.dumps(alias_map) |
| background_json = json.dumps(background_uri) |
| helper_script = f"""<script> |
| (function () {{ |
| var ASSETS = {asset_json}; |
| var ALIASES = {alias_json}; |
| window.GENERATED_GAME_ASSETS = ASSETS; |
| |
| function basename(value) {{ |
| return String(value || "").split("?")[0].split("#")[0].split("/").pop().toLowerCase(); |
| }} |
| |
| function pickAsset(value) {{ |
| var text = String(value || "").toLowerCase(); |
| if (!text || text.indexOf("data:image/") === 0) return value; |
| var file = basename(text); |
| for (var role in ASSETS) {{ |
| var aliases = ALIASES[role] || [role]; |
| for (var i = 0; i < aliases.length; i++) {{ |
| var alias = String(aliases[i]).toLowerCase(); |
| if (!alias) continue; |
| if (file === alias || file === alias + ".png" || file === "sprite_" + alias + ".png") return ASSETS[role]; |
| if (file.indexOf(alias) !== -1 || text.indexOf("/" + alias) !== -1 || text.indexOf("_" + alias) !== -1) return ASSETS[role]; |
| }} |
| }} |
| return value; |
| }} |
| |
| var descriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src"); |
| if (descriptor && descriptor.set && !HTMLImageElement.prototype.__generatedAssetMapper) {{ |
| Object.defineProperty(HTMLImageElement.prototype, "src", {{ |
| get: function () {{ return descriptor.get.call(this); }}, |
| set: function (value) {{ descriptor.set.call(this, pickAsset(value)); }}, |
| configurable: true, |
| enumerable: descriptor.enumerable |
| }}); |
| HTMLImageElement.prototype.__generatedAssetMapper = true; |
| }} |
| |
| var originalDrawImage = CanvasRenderingContext2D.prototype.drawImage; |
| if (!CanvasRenderingContext2D.prototype.__generatedAssetDrawGuard) {{ |
| CanvasRenderingContext2D.prototype.drawImage = function (image) {{ |
| try {{ |
| if (image instanceof HTMLImageElement) {{ |
| var current = image.getAttribute("src") || image.src || ""; |
| var mapped = pickAsset(current); |
| if (mapped && mapped !== current) image.src = mapped; |
| if (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0) {{ |
| var looksLikeBackground = /background|backdrop|scene|map|level|bg/i.test(current); |
| var coversCanvas = arguments.length >= 5 && arguments[1] === 0 && arguments[2] === 0 && |
| arguments[3] >= this.canvas.width * 0.8 && arguments[4] >= this.canvas.height * 0.8; |
| if (looksLikeBackground || coversCanvas) {{ |
| this.clearRect(0, 0, this.canvas.width, this.canvas.height); |
| }} |
| return; |
| }} |
| }} |
| return originalDrawImage.apply(this, arguments); |
| }} catch (error) {{ |
| return; |
| }} |
| }}; |
| CanvasRenderingContext2D.prototype.__generatedAssetDrawGuard = true; |
| }} |
| |
| window.addEventListener("DOMContentLoaded", function () {{ |
| document.querySelectorAll("img").forEach(function (img) {{ |
| var mapped = pickAsset(img.getAttribute("src") || img.src); |
| if (mapped !== (img.getAttribute("src") || img.src)) img.src = mapped; |
| }}); |
| var background = {background_json}; |
| if (!background) return; |
| document.querySelectorAll("canvas").forEach(function (canvas) {{ |
| canvas.style.backgroundImage = "url(" + background + ")"; |
| canvas.style.backgroundSize = "cover"; |
| canvas.style.backgroundPosition = "center"; |
| }}); |
| }}); |
| }})(); |
| </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 build_prompt_preview(specs: list[AssetSpec]) -> str: |
| return "\n\n".join(f"{spec.role}:\n{spec.prompt}" for spec in specs) |
|
|
|
|
| def build_model_report(rows: list[tuple[str, str, str]]) -> str: |
| return "\n".join(f"{role}: prompt={prompt_model}; image={image_model}" for role, prompt_model, image_model in rows) |
|
|
|
|
| def summarize_model_sources(rows: list[tuple[str, str, str]]) -> str: |
| prompt_sources = sorted({prompt_model for _, prompt_model, _ in rows}) |
| image_sources = sorted({image_model for _, _, image_model in rows}) |
| return f"prompt={', '.join(prompt_sources)}; image={', '.join(image_sources)}" |
|
|
|
|
| def generate_images_and_game(html_code: str, roles: str, style_hint: str): |
| if not html_code.strip(): |
| return "", "Paste HTML game code first.", [], "", "", "" |
|
|
| role_lines, prompt_map, prompt_model, prompt_error = build_prompt_map(html_code, roles, style_hint or "pixel art style") |
| specs = parse_assets(roles, style_hint or "pixel art style", prompt_map) |
| 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 = [] |
| model_rows = [] |
| run_id = time.time_ns() |
| if prompt_error: |
| errors.append(f"prompt model: {prompt_error}") |
|
|
| for index, spec in enumerate(specs): |
| data_uri, gallery_path, error, image_model = generate_asset(spec, index, run_id) |
| assets[spec.role] = data_uri |
| gallery.append((gallery_path, f"{spec.role} -> {spec.filename}")) |
| model_rows.append((spec.role, prompt_model, image_model)) |
| if error: |
| errors.append(f"{spec.role}: image model failed ({error}); used local procedural fallback") |
|
|
| rewritten = embed_assets(html_code, assets, specs) |
| status = ( |
| f"Generated and embedded {len(specs)} fresh asset(s) using " |
| f"{summarize_model_sources(model_rows)}. Run {str(run_id)[-6:]}." |
| ) |
| if errors: |
| status += "\n\n" + "\n".join(errors) |
| return rewritten, status, gallery, build_prompt_preview(specs), build_model_report(model_rows), build_preview(rewritten) |
|
|
|
|
| def check_hf_token() -> str: |
| if not HF_TOKEN: |
| return "HF_TOKEN is missing or not visible to the Space runtime." |
|
|
| request = Request( |
| "https://huggingface.co/api/whoami-v2", |
| headers={"Authorization": f"Bearer {HF_TOKEN}"}, |
| method="GET", |
| ) |
| try: |
| with urlopen(request, timeout=30) as response: |
| raw = response.read().decode("utf-8", errors="replace") |
| data = json.loads(raw) |
| name = data.get("name") or data.get("fullname") or "authenticated account" |
| return f"HF_TOKEN is visible and valid for {name}." |
| except Exception as exc: |
| return f"HF_TOKEN check failed: {short_error(exc)}" |
|
|
|
|
| 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", |
| lines=8, |
| placeholder=ROLE_PLACEHOLDER, |
| info="One per line: role: image description. Example: player: blue robot hero", |
| ) |
| style = gr.Textbox( |
| label="Shared visual style", |
| lines=2, |
| placeholder="Optional: describe the art style/theme, or leave blank.", |
| ) |
| generate_btn = gr.Button("Generate Images + Embed Game", variant="primary") |
| status = gr.Markdown("Ready.") |
| token_btn = gr.Button("Check HF Token") |
| token_status = gr.Markdown("") |
| gallery = gr.Gallery(label="Generated assets", columns=2, height=300) |
|
|
| with gr.Column(scale=2): |
| html_input = gr.Textbox( |
| label="Original HTML game code", |
| lines=18, |
| placeholder="Paste your full HTML game code here.", |
| ) |
| output_code = gr.Code( |
| label="Rewritten HTML with embedded images", |
| language="html", |
| lines=18, |
| ) |
| prompt_preview = gr.Textbox( |
| label="Interpreted image prompts", |
| lines=8, |
| interactive=False, |
| ) |
| model_report = gr.Textbox( |
| label="Model/source used by role", |
| lines=5, |
| interactive=False, |
| ) |
|
|
| gr.Markdown("## Game preview") |
| preview = gr.HTML("") |
|
|
| generate_btn.click( |
| fn=generate_images_and_game, |
| inputs=[html_input, roles, style], |
| outputs=[output_code, status, gallery, prompt_preview, model_report, preview], |
| ) |
| token_btn.click(fn=check_hf_token, inputs=None, outputs=token_status) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|