feat(sprite-generation): Add AI-powered sprite generation with new prompt strategies and registry integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:23 -07:00
parent c08ad31b8a
commit 005c856053
3 changed files with 73 additions and 59 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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: