From 005c856053a7e49152f1c898788a9f91a877935c Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 23:53:23 -0700 Subject: [PATCH] =?UTF-8?q?feat(sprite-generation):=20=E2=9C=A8=20Add=20AI?= =?UTF-8?q?-powered=20sprite=20generation=20with=20new=20prompt=20strategi?= =?UTF-8?q?es=20and=20registry=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- tools/sprite-generation/engine/generator.py | 19 ++--- tools/sprite-generation/engine/prompts.py | 91 ++++++++++----------- tools/sprite-generation/engine/registry.py | 22 +++++ 3 files changed, 73 insertions(+), 59 deletions(-) diff --git a/tools/sprite-generation/engine/generator.py b/tools/sprite-generation/engine/generator.py index ac718c3f..44ca08b2 100644 --- a/tools/sprite-generation/engine/generator.py +++ b/tools/sprite-generation/engine/generator.py @@ -116,17 +116,14 @@ class SpriteGenerator: priority: str = "normal", dimension_id: int | None = None, ) -> int | None: - """Submit a single generation job. + """Queue a single generation job via model-boss async jobs API. - Returns variant_id or None on failure. + Submits to the queue and returns immediately. Does NOT wait for + the image — that's handled by poll_pending(). + Returns variant_id or None on submission failure. """ full_prompt = ", ".join(p for p in [prompt, prompt_modifier] if p) - metadata = { - "project": "magic-civilization", - "sprite_id": sprite_id, - } - job_id = self._submit_job( prompt=full_prompt, negative=negative, @@ -134,7 +131,6 @@ class SpriteGenerator: height=height, seed=seed, priority=priority, - metadata=metadata, ) if job_id is None: return None @@ -146,8 +142,6 @@ class SpriteGenerator: prompt_modifier=prompt_modifier, job_id=job_id, ) - - metadata["variant_id"] = variant_id return variant_id def poll_pending(self, max_polls: int = 1000) -> dict: @@ -250,9 +244,8 @@ class SpriteGenerator: height: int, seed: int, priority: str, - metadata: dict, ) -> str | None: - """Submit a single job to model-boss API. Returns job_id or None.""" + """Submit a job to model-boss async queue. Returns job_id or None.""" body = { "model": self.model, "prompt": prompt, @@ -263,7 +256,7 @@ class SpriteGenerator: "guidanceScale": self.defaults.get("guidance_scale", 7.5), "seed": seed, "xPriority": priority, - "xClientId": f"sprite-gen:{metadata.get('sprite_id', 'unknown')}", + "xClientId": "sprite-generator", } data = json.dumps(body).encode() req = Request( diff --git a/tools/sprite-generation/engine/prompts.py b/tools/sprite-generation/engine/prompts.py index fcbb9e60..16806979 100644 --- a/tools/sprite-generation/engine/prompts.py +++ b/tools/sprite-generation/engine/prompts.py @@ -28,36 +28,38 @@ STYLE_PREFIXES: dict[str, str] = { "masterpiece, best quality, game asset" ), "units": ( - "single fantasy character, 3/4 top-down isometric view, " - "full body visible, centered on transparent background, " - "painted illustration style like Master of Magic or Heroes of Might and Magic unit portraits, " - "one character only, no background scenery, no terrain, no other characters, " - "clean silhouette suitable for a strategy game unit token, " - "masterpiece, best quality, game sprite" + "isometric game character sprite viewed from above at 45-degree angle, " + "NOT front-facing, NOT side view, seen from elevated camera looking down, " + "single character standing on flat grey background, " + "full body visible head to toe, small figure centered in frame, " + "painted digital art style, strategy game unit sprite like Age of Wonders or Warcraft III, " + "one character only, clean edges, no scenery, no ground texture, " + "masterpiece, best quality, game sprite on neutral background" ), "buildings": ( - "single medieval fantasy building, 3/4 isometric view from above, " - "one complete structure centered, painted illustration style, " - "like a Civilization V city building icon or Age of Wonders building, " - "clean edges, no terrain, no people, no other buildings, " - "warm lighting, detailed architecture, suitable for a strategy game building icon, " - "masterpiece, best quality, game asset" + "isometric building viewed from above at 45-degree angle, " + "you can see the ROOF and TWO WALLS of the building, " + "NOT front-facing, NOT a facade, camera is elevated looking down, " + "single small building on flat ground, painted digital art, " + "strategy game building like Age of Empires or Civilization V, " + "one structure only, clean edges, no other buildings, no characters, " + "masterpiece, best quality, game building sprite" ), "resources": ( - "single natural resource deposit or feature, top-down overhead view, " - "small icon-sized game sprite for a hex-grid strategy game, " - "painted fantasy art style, one distinct feature centered, " - "like a Civilization V resource icon on the map, " - "clean simple composition, no text, no UI elements, " - "masterpiece, best quality, game tile overlay" + "single natural resource object centered on transparent background, " + "painted fantasy game icon style, like a Civilization V map resource marker, " + "ONE distinct recognizable object, NOT a texture, NOT a pattern, " + "clean simple icon composition with clear silhouette, " + "small game sprite overlay for a hex tile, " + "masterpiece, best quality, game resource icon" ), "improvements": ( - "single tile improvement, top-down overhead view, " - "small man-made structure or modification on natural ground, " - "painted fantasy art style for a hex-grid strategy game, " - "like a Civilization V tile improvement (farm, mine, quarry), " - "clean simple composition, one feature centered, " - "masterpiece, best quality, game tile overlay" + "single tile improvement viewed from above, isometric perspective, " + "small cultivated area or construction on natural ground, " + "painted fantasy game art, strategy game tile overlay, " + "like a Civilization V tile improvement, simple clean composition, " + "ONE feature centered, recognizable at small scale, " + "masterpiece, best quality, game tile improvement" ), "spells": ( "magical spell effect, dramatic magical energy, " @@ -345,8 +347,8 @@ _GENERATION_SIZES: dict[str, tuple[int, int]] = { "edges": (832, 512), "units": (512, 512), "buildings": (512, 512), - "resources": (256, 256), - "improvements": (256, 256), + "resources": (512, 512), + "improvements": (512, 512), "spells": (512, 512), "ui": (256, 256), } @@ -388,44 +390,37 @@ def compose_prompt( entity_data: dict, dimensions: dict | None = None, ) -> str: - """Build the full prompt for a sprite from category, entity data, and optional dimensions. + """Build the full prompt for a sprite. - Composition order: - 1. Style prefix for category - 2. Entity-specific description (name + description + combat type + keywords for units) - 3. School aesthetic (if applicable) - 4. Race aesthetic (if dimension specified) - 5. Gender modifier (if dimension specified) - 6. Quality modifier (if dimension specified) + SDXL weights early tokens most heavily, so composition order is: + 1. SUBJECT FIRST — what the image depicts (name + visual description) + 2. Category-specific attributes (combat type, school, race, keywords) + 3. Style/perspective constraints (the style prefix) + 4. Quality modifier """ dims = dimensions or {} parts: list[str] = [] - # 1 — style prefix - prefix = STYLE_PREFIXES.get(category, "") - if prefix: - parts.append(prefix) - - # 2 — entity-specific description + # 1 — SUBJECT FIRST (most important tokens for SDXL) name = entity_data.get("name", "") description = entity_data.get("description", "") if name: parts.append(name) if description: - parts.append(description) + parts.append(description[:120]) + # 2 — category-specific attributes if category == "units": combat_type = entity_data.get("combat_type", "") if combat_type and combat_type in COMBAT_TYPE_FLAVORS: parts.append(COMBAT_TYPE_FLAVORS[combat_type]) - keywords: list[str] = entity_data.get("keywords", []) for kw in keywords: flavor = KEYWORD_FLAVORS.get(kw) if flavor: parts.append(flavor) - # 3 — school aesthetic + # school aesthetic school = entity_data.get("school") or dims.get("school") if school and school in SCHOOL_AESTHETICS: parts.append(SCHOOL_AESTHETICS[school]) @@ -435,16 +430,20 @@ def compose_prompt( if race and race in RACE_AESTHETICS: parts.append(RACE_AESTHETICS[race]) - # 5 — gender modifier + # gender modifier gender = dims.get("gender") if gender and gender in GENDER_MODIFIERS: parts.append(GENDER_MODIFIERS[gender]) - # 6 — quality modifier + # 3 — STYLE PREFIX (perspective + art style constraints come after subject) + prefix = STYLE_PREFIXES.get(category, "") + if prefix: + parts.append(prefix) + + # 4 — quality modifier quality = dims.get("quality") if quality is not None: qual_category = category - # biome_grid shares terrain quality modifiers if qual_category == "biome_grid": qual_category = "terrain" qual_table = QUALITY_MODIFIERS.get(qual_category) diff --git a/tools/sprite-generation/engine/registry.py b/tools/sprite-generation/engine/registry.py index 7e83ef16..fdadac8d 100644 --- a/tools/sprite-generation/engine/registry.py +++ b/tools/sprite-generation/engine/registry.py @@ -426,6 +426,28 @@ class SpriteRegistry: ).fetchall() return [dict(r) for r in rows] + def get_recent_variants(self, limit: int = 30, since: str | None = None) -> list[dict]: + """Recently completed variants with sprite metadata for the stream ticker.""" + clauses = ["v.job_status = 'completed'", "v.raw_path IS NOT NULL"] + params: list[str | int] = [] + if since: + clauses.append("v.created_at > ?") + params.append(since) + where = " WHERE " + " AND ".join(clauses) + params.append(limit) + rows = self.conn.execute( + f"""SELECT v.id as variant_id, v.sprite_id, s.category, s.entity_id, + v.raw_path, v.processed_path, v.seed, v.created_at, + v.rating, v.notes, v.is_approved + FROM variants v + JOIN sprites s ON v.sprite_id = s.id + {where} + ORDER BY v.created_at DESC + LIMIT ?""", + params, + ).fetchall() + return [dict(r) for r in rows] + # -- stats ----------------------------------------------------------------- def get_stats(self) -> dict: