spriteforge 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. spriteforge/__init__.py +1 -0
  2. spriteforge/__main__.py +3 -0
  3. spriteforge/agents/__init__.py +0 -0
  4. spriteforge/agents/assets.py +109 -0
  5. spriteforge/agents/cancellation.py +12 -0
  6. spriteforge/agents/director.py +50 -0
  7. spriteforge/agents/models.py +63 -0
  8. spriteforge/agents/roles/art-director.md +15 -0
  9. spriteforge/agents/roles/art-reviewer.md +14 -0
  10. spriteforge/agents/roles/director.md +14 -0
  11. spriteforge/agents/roles/game-designer.md +15 -0
  12. spriteforge/agents/roles/level-tile-designer.md +16 -0
  13. spriteforge/agents/roles/librarian.md +13 -0
  14. spriteforge/agents/roles/menu-designer.md +15 -0
  15. spriteforge/agents/roles/pixel-artist.md +14 -0
  16. spriteforge/agents/roles/programmer.md +14 -0
  17. spriteforge/agents/roles/qa-playtester.md +13 -0
  18. spriteforge/agents/roles/sound-director.md +14 -0
  19. spriteforge/agents/roles.py +39 -0
  20. spriteforge/agents/router.py +233 -0
  21. spriteforge/agents/router_eval.py +145 -0
  22. spriteforge/agents/routing_state.py +133 -0
  23. spriteforge/agents/runtime.py +430 -0
  24. spriteforge/agents/workflow.py +389 -0
  25. spriteforge/app/__init__.py +0 -0
  26. spriteforge/app/agents_view.py +248 -0
  27. spriteforge/app/chat.py +114 -0
  28. spriteforge/app/extensions.py +115 -0
  29. spriteforge/app/index.html +279 -0
  30. spriteforge/app/local_only.py +49 -0
  31. spriteforge/app/server.py +358 -0
  32. spriteforge/app/static/assets/index-L2s1wlcp.css +1 -0
  33. spriteforge/app/static/assets/index-aBfzgLQl.js +71 -0
  34. spriteforge/app/static/assets/press-start-2p-latin-400-normal-BKfmdmKK.woff +0 -0
  35. spriteforge/app/static/assets/press-start-2p-latin-400-normal-xj9lRvVs.woff2 +0 -0
  36. spriteforge/app/static/index.html +13 -0
  37. spriteforge/app/static/press-start-2p-LICENSE.txt +93 -0
  38. spriteforge/app/studio.py +278 -0
  39. spriteforge/app/workers.py +73 -0
  40. spriteforge/cli.py +241 -0
  41. spriteforge/config.py +193 -0
  42. spriteforge/config.yaml +157 -0
  43. spriteforge/core/__init__.py +0 -0
  44. spriteforge/core/acting.py +16 -0
  45. spriteforge/core/cost.py +236 -0
  46. spriteforge/core/credentials.py +24 -0
  47. spriteforge/core/demo.py +79 -0
  48. spriteforge/core/forms.py +20 -0
  49. spriteforge/core/gates.py +175 -0
  50. spriteforge/core/security.py +42 -0
  51. spriteforge/core/state.py +222 -0
  52. spriteforge/core/team.py +15 -0
  53. spriteforge/core/usage.py +68 -0
  54. spriteforge/game/__init__.py +0 -0
  55. spriteforge/game/actors.py +377 -0
  56. spriteforge/game/agent_tools.py +178 -0
  57. spriteforge/game/art.py +2097 -0
  58. spriteforge/game/art_checks.py +190 -0
  59. spriteforge/game/art_review.py +518 -0
  60. spriteforge/game/build.py +134 -0
  61. spriteforge/game/bundle.py +1178 -0
  62. spriteforge/game/cli.py +119 -0
  63. spriteforge/game/codegen.py +433 -0
  64. spriteforge/game/codeinfo.py +154 -0
  65. spriteforge/game/dbsource.py +137 -0
  66. spriteforge/game/design.py +342 -0
  67. spriteforge/game/export.py +62 -0
  68. spriteforge/game/gamedb.py +191 -0
  69. spriteforge/game/image_cache.py +61 -0
  70. spriteforge/game/lessons.py +259 -0
  71. spriteforge/game/level_gen.py +118 -0
  72. spriteforge/game/level_preview.py +97 -0
  73. spriteforge/game/levels.py +934 -0
  74. spriteforge/game/loops.py +38 -0
  75. spriteforge/game/menus.py +712 -0
  76. spriteforge/game/mock_game.js +109 -0
  77. spriteforge/game/mock_platformer.js +58 -0
  78. spriteforge/game/motion.py +881 -0
  79. spriteforge/game/pipeline.py +1768 -0
  80. spriteforge/game/plan.py +502 -0
  81. spriteforge/game/playtest.py +318 -0
  82. spriteforge/game/qa.py +664 -0
  83. spriteforge/game/qa_checks.py +223 -0
  84. spriteforge/game/qa_runtime.py +565 -0
  85. spriteforge/game/reach.py +807 -0
  86. spriteforge/game/record.py +138 -0
  87. spriteforge/game/research.py +262 -0
  88. spriteforge/game/revision.py +225 -0
  89. spriteforge/game/sdk/API.md +104 -0
  90. spriteforge/game/sdk/FLOW.md +19 -0
  91. spriteforge/game/sdk/LEVELS.md +65 -0
  92. spriteforge/game/sdk/sf.js +418 -0
  93. spriteforge/game/sdk/sfflow.js +442 -0
  94. spriteforge/game/sdk/sflevel.js +683 -0
  95. spriteforge/game/sdk/sfsound-editor.js +225 -0
  96. spriteforge/game/sdk/sfsound.js +260 -0
  97. spriteforge/game/sdk/sounds.html +67 -0
  98. spriteforge/game/sounds.py +793 -0
  99. spriteforge/game/story.py +188 -0
  100. spriteforge/game/structure.py +98 -0
  101. spriteforge/game/team_reports.py +394 -0
  102. spriteforge/game/tilesets.py +70 -0
  103. spriteforge/game/title_art.py +186 -0
  104. spriteforge/genres/__init__.py +0 -0
  105. spriteforge/library/__init__.py +15 -0
  106. spriteforge/library/cli.py +126 -0
  107. spriteforge/library/describe.py +242 -0
  108. spriteforge/library/looks.py +154 -0
  109. spriteforge/library/models.py +79 -0
  110. spriteforge/library/store.py +422 -0
  111. spriteforge/library/tilesets/README.md +32 -0
  112. spriteforge/library/tilesets/gandalf-seasons.json +111 -0
  113. spriteforge/library/tilesets/pixel-crawler-dungeon.json +66 -0
  114. spriteforge/mcp/__init__.py +0 -0
  115. spriteforge/mcp/cli.py +58 -0
  116. spriteforge/mcp/client.py +57 -0
  117. spriteforge/mcp/filesystem_read.py +32 -0
  118. spriteforge/mcp/gamedb.py +56 -0
  119. spriteforge/mcp/library.py +179 -0
  120. spriteforge/mcp/memory.py +37 -0
  121. spriteforge/mcp/pixel_tools.py +145 -0
  122. spriteforge/mcp/registry.py +145 -0
  123. spriteforge/mcp/service.py +224 -0
  124. spriteforge/mcp/templates/context7.json +6 -0
  125. spriteforge/mcp/templates/fetch.json +6 -0
  126. spriteforge/mcp/templates/filesystem-read.json +6 -0
  127. spriteforge/mcp/templates/pixel-tools.json +6 -0
  128. spriteforge/memory/README.md +10 -0
  129. spriteforge/memory/__init__.py +0 -0
  130. spriteforge/memory/cli.py +131 -0
  131. spriteforge/memory/okf/__init__.py +1 -0
  132. spriteforge/memory/okf/catalog.py +331 -0
  133. spriteforge/memory/okf/codec.py +63 -0
  134. spriteforge/memory/okf/git.py +104 -0
  135. spriteforge/memory/okf/indexes.py +50 -0
  136. spriteforge/memory/okf/models.py +114 -0
  137. spriteforge/memory/okf/search.py +53 -0
  138. spriteforge/memory/okf/seed.py +81 -0
  139. spriteforge/memory/okf/seeds/genres.yaml +121 -0
  140. spriteforge/memory/okf/seeds/other.yaml +150 -0
  141. spriteforge/memory/okf/store.py +402 -0
  142. spriteforge/memory/okf/validate.py +115 -0
  143. spriteforge/pixel/__init__.py +17 -0
  144. spriteforge/pixel/atlas.py +110 -0
  145. spriteforge/pixel/grid.py +174 -0
  146. spriteforge/pixel/palette.py +156 -0
  147. spriteforge/pixel/palettes/dungeon.json +9 -0
  148. spriteforge/pixel/postprocess.py +170 -0
  149. spriteforge/pixel/preview.py +40 -0
  150. spriteforge/pixel/schemas.py +139 -0
  151. spriteforge/pixel/source.py +237 -0
  152. spriteforge/pixel/terrain.py +48 -0
  153. spriteforge/pixel/validate.py +138 -0
  154. spriteforge/prompts/anim_frame.txt +1 -0
  155. spriteforge/prompts/character.txt +1 -0
  156. spriteforge/prompts/item.txt +1 -0
  157. spriteforge/prompts/tile.txt +1 -0
  158. spriteforge/prompts/ui.txt +1 -0
  159. spriteforge/providers/__init__.py +0 -0
  160. spriteforge/providers/base.py +97 -0
  161. spriteforge/providers/http.py +38 -0
  162. spriteforge/providers/mock.py +111 -0
  163. spriteforge/providers/muse_image.py +60 -0
  164. spriteforge/providers/muse_search.py +91 -0
  165. spriteforge/providers/muse_spark.py +66 -0
  166. spriteforge/skills/__init__.py +0 -0
  167. spriteforge/skills/builtin/LICENSE-awesome-gamedev-agent-skills.txt +201 -0
  168. spriteforge/skills/builtin/NOTICE-awesome-gamedev-agent-skills.txt +15 -0
  169. spriteforge/skills/builtin/THIRD_PARTY.md +21 -0
  170. spriteforge/skills/builtin/audio-design/SKILL.md +190 -0
  171. spriteforge/skills/builtin/game-feel/SKILL.md +174 -0
  172. spriteforge/skills/builtin/game-ui-ux/SKILL.md +167 -0
  173. spriteforge/skills/builtin/level-design/SKILL.md +169 -0
  174. spriteforge/skills/builtin/phaser-arcade-physics/SKILL.md +17 -0
  175. spriteforge/skills/builtin/physics-tuning/SKILL.md +164 -0
  176. spriteforge/skills/builtin/pixel-art-prompting/SKILL.md +12 -0
  177. spriteforge/skills/builtin/platformer/SKILL.md +153 -0
  178. spriteforge/skills/builtin/puzzle/SKILL.md +143 -0
  179. spriteforge/skills/builtin/sprite-animation-frames/SKILL.md +11 -0
  180. spriteforge/skills/builtin/tileset-design/SKILL.md +10 -0
  181. spriteforge/skills/catalog.py +200 -0
  182. spriteforge/skills/cli.py +41 -0
  183. spriteforge/stages/__init__.py +0 -0
  184. spriteforge/stages/asset_context.py +301 -0
  185. spriteforge/stages/asset_models.py +192 -0
  186. spriteforge/stages/asset_render.py +366 -0
  187. spriteforge/stages/asset_review.py +140 -0
  188. spriteforge/stages/asset_store.py +94 -0
  189. spriteforge/stages/chat_changes.py +236 -0
  190. spriteforge/stages/concept.py +42 -0
  191. spriteforge/stages/game.py +1699 -0
  192. spriteforge/stages/game_confirm.py +112 -0
  193. spriteforge/stages/game_final.py +220 -0
  194. spriteforge/stages/game_record.py +246 -0
  195. spriteforge/stages/gameplay.py +218 -0
  196. spriteforge/stages/intake.py +372 -0
  197. spriteforge/stages/preview_assets.py +212 -0
  198. spriteforge/stages/preview_models.py +167 -0
  199. spriteforge/stages/preview_render.py +169 -0
  200. spriteforge/stages/preview_repair.py +61 -0
  201. spriteforge/stages/preview_review.py +169 -0
  202. spriteforge/stages/production.py +170 -0
  203. spriteforge/stages/references.py +105 -0
  204. spriteforge/stages/rewind.py +128 -0
  205. spriteforge/stages/workflow.py +440 -0
  206. spriteforge/templates/phaser/audit.js +45 -0
  207. spriteforge/templates/phaser/font.png +0 -0
  208. spriteforge/templates/phaser/font.xml +1 -0
  209. spriteforge/templates/phaser/preview.html +9 -0
  210. spriteforge/templates/phaser/room.js +49 -0
  211. spriteforge/templates/phaser/vendor/LICENSE.md +20 -0
  212. spriteforge/templates/phaser/vendor/phaser.min.js +1 -0
  213. spriteforge-0.1.1.dist-info/METADATA +364 -0
  214. spriteforge-0.1.1.dist-info/RECORD +218 -0
  215. spriteforge-0.1.1.dist-info/WHEEL +5 -0
  216. spriteforge-0.1.1.dist-info/entry_points.txt +2 -0
  217. spriteforge-0.1.1.dist-info/licenses/LICENSE +27 -0
  218. spriteforge-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,126 @@
1
+ """`spriteforge library ...`: ingest downloaded packs and browse the catalog."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from . import store
9
+
10
+ app = typer.Typer(no_args_is_help=True, help="Third-party asset library with license tracking.")
11
+
12
+
13
+ def _cell(value: str) -> tuple[int, int] | str | None:
14
+ if not value:
15
+ return None
16
+ if value == "auto":
17
+ return "auto"
18
+ w, _, h = value.lower().partition("x")
19
+ return int(w), int(h or w)
20
+
21
+
22
+ @app.command()
23
+ def add(
24
+ source: Path,
25
+ pack: Annotated[str, typer.Option(help="Pack id, e.g. kenney-tiny-dungeon.")],
26
+ category: Annotated[str, typer.Option(help="character/enemy/tileset/item/...")] = "other",
27
+ subcategory: str = "",
28
+ title: str = "",
29
+ tag: Annotated[list[str] | None, typer.Option(help="Extra tag (repeatable).")] = None,
30
+ cell: Annotated[str, typer.Option(help="Grid cell for sheets, e.g. 16x16.")] = "",
31
+ author: str = "unknown",
32
+ source_url: str = "",
33
+ license: Annotated[str, typer.Option(help="CC0, CC-BY-4.0, ... or UNKNOWN.")] = "UNKNOWN",
34
+ license_url: str = "",
35
+ verified: Annotated[bool, typer.Option(help="License checked on the source page.")] = False,
36
+ notes: str = "",
37
+ ):
38
+ """Add a downloaded .zip, folder or image to assets/library/."""
39
+ result = store.add_pack(
40
+ source, pack, title=title, category=category, subcategory=subcategory, tags=tag or [],
41
+ cell=_cell(cell), author=author, source_url=source_url, license=license,
42
+ license_url=license_url, license_verified=verified, notes=notes,
43
+ ) # fmt: skip
44
+ typer.echo(f"{result.id}: {len(result.assets)} assets, usage={result.usage}")
45
+ if result.usage == "reference-only":
46
+ typer.echo(" License not verified: builds will not ship this art. Use `library license`.")
47
+
48
+
49
+ @app.command("list")
50
+ def list_cmd():
51
+ """Show packs with their license status."""
52
+ for p in store.load_packs():
53
+ typer.echo(f"{p.id:32} {len(p.assets):4} assets {p.license:12} {p.usage}")
54
+
55
+
56
+ @app.command()
57
+ def search(category: str = "", tag: str = "", cell: str = "", usage: str = ""):
58
+ """Find assets, e.g. --category equipment --tag chainmail --usage ship."""
59
+ for a in store.search(category=category, tag=tag, size=_cell(cell), usage=usage):
60
+ typer.echo(f"{a['id']:44} {a['cell'][0]}x{a['cell'][1]} {a['usage']:16} {a['file']}")
61
+
62
+
63
+ @app.command()
64
+ def describe(
65
+ pack: Annotated[list[str] | None, typer.Option(help="Only this pack (repeatable).")] = None,
66
+ force: Annotated[bool, typer.Option(help="Describe again what is already described.")] = False,
67
+ mock: Annotated[bool, typer.Option(help="Offline: measured looks only, no model.")] = False,
68
+ max_calls: Annotated[int, typer.Option(help="Cap on Muse Spark calls.")] = 30,
69
+ max_cost: Annotated[float, typer.Option(help="Cap on the estimated cost, USD.")] = 0.5,
70
+ config: Path | None = None,
71
+ workspace: Path | None = None,
72
+ ):
73
+ """Describe every library image: a measured look (colours, brightness, detail) for all,
74
+ and a Muse Spark (vision) description for packs that allow AI use."""
75
+ from spriteforge.config import BudgetConfig, init_workspace, load_config
76
+ from spriteforge.core.cost import Budget
77
+ from spriteforge.core.credentials import load_credentials
78
+ from spriteforge.core.state import Store
79
+ from spriteforge.providers.muse_spark import MuseSparkProvider
80
+
81
+ from .describe import describe_library
82
+
83
+ cfg = load_config(config)
84
+ provider, budget = None, None
85
+ if not mock:
86
+ load_credentials()
87
+ root = init_workspace(workspace or cfg.workspace)
88
+ store = Store(root / "library.sqlite3")
89
+ cap = BudgetConfig(**{**cfg.budget.model_dump(), "max_calls_per_model": max_calls,
90
+ "max_estimated_cost": max_cost}) # fmt: skip
91
+ run = store.create_run({"workflow": "library-describe", "budget": cap.model_dump()})
92
+ budget = Budget(store, run.id, cap)
93
+ provider = MuseSparkProvider(cfg.llm, budget)
94
+ counts = describe_library(provider=provider, model=cfg.llm.model if provider else "",
95
+ reasoning=cfg.llm.reasoning.get("describe"), packs=pack or None,
96
+ force=force, log=typer.echo) # fmt: skip
97
+ if provider is not None:
98
+ provider.close()
99
+ typer.echo(
100
+ f"{counts['model']} described by the model, {counts['measured']} measured only, "
101
+ f"{counts['kept']} unchanged, {counts['failed']} not described"
102
+ + (f"; about ${budget.spent():.3f}" if budget else "")
103
+ )
104
+
105
+
106
+ @app.command()
107
+ def mcp():
108
+ """Expose read-only library tools (packs, search, actors, tile sets) over MCP stdio."""
109
+ from spriteforge.mcp.library import create_server
110
+
111
+ create_server().run(transport="stdio")
112
+
113
+
114
+ @app.command()
115
+ def license(
116
+ pack: str,
117
+ license: str,
118
+ source_url: str = "",
119
+ license_url: str = "",
120
+ author: str = "",
121
+ ):
122
+ """Record a license you checked on the pack's download page (marks it verified)."""
123
+ p = store.set_license(
124
+ pack, license, source_url=source_url, license_url=license_url, author=author
125
+ )
126
+ typer.echo(f"{p.id}: license={p.license} usage={p.usage}")
@@ -0,0 +1,242 @@
1
+ """Descriptions of every library image, so the harness can choose art by what it shows.
2
+
3
+ `describe_library` gives every asset, and every named tile of an installed tile set, a measured
4
+ look (`looks.measure`: colours, brightness, detail; free). Images from packs that allow AI use
5
+ are also described by Muse Spark (vision): numbered contact sheets of up to 16 images, one
6
+ call each, answering what each image is, one sentence on what it shows and the camera it is
7
+ drawn for. Packs whose license forbids AI use never reach a model: their entry is the file
8
+ name and the measured look.
9
+
10
+ Entries are written to `<library>/descriptions/<pack>.json` (`{"assets": {id: entry},
11
+ "tiles": {tile id: entry}}`) with the image's sha256, so a changed image is described again
12
+ and an unchanged one is not sent twice. Model-written entries are `status: generated`; edit
13
+ the text, set `status: reviewed`, and later runs keep it. `rebuild_index` merges the entries
14
+ into index.json, so `search()` (the art step, the MCP tools, the catalog) returns them.
15
+ """
16
+
17
+ import hashlib
18
+ import io
19
+ import json
20
+ import math
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Literal
25
+
26
+ from PIL import Image, ImageDraw, ImageFont
27
+ from pydantic import Field
28
+
29
+ from spriteforge.agents.models import Contract
30
+ from spriteforge.core.cost import BudgetExceeded
31
+ from spriteforge.providers.base import ChatRequest, LLMProvider, Message, chat_json
32
+
33
+ from .looks import Look, asset_image, measure
34
+ from .store import library_root, load_descriptions, load_packs, rebuild_index
35
+
36
+ PER_SHEET = 16
37
+ CELL = 128 # px per image on the contact sheet
38
+ LABEL = 18
39
+ SHEET_BACKGROUND = (128, 128, 132, 255)
40
+ View = Literal["top-down", "3/4 top-down", "side", "front", "flat", "unknown"]
41
+
42
+
43
+ class Seen(Contract):
44
+ n: int = Field(description="the image's number on the sheet")
45
+ what: str = Field(description="what it is, 1-4 words, e.g. 'hooded rogue', 'stone floor tile'")
46
+ description: str = Field(
47
+ description="one sentence, at most 30 words: the subject, its main colours by name and "
48
+ "whether it is light or dark, and the details that matter when choosing game art"
49
+ )
50
+ view: View = Field(description="the camera the art is drawn for")
51
+
52
+
53
+ class SheetAnswer(Contract):
54
+ images: list[Seen]
55
+
56
+
57
+ @dataclass
58
+ class Item:
59
+ key: str # asset id, or tile id (`<tileset>/<tile>`)
60
+ section: Literal["assets", "tiles"]
61
+ pack: str
62
+ name: str
63
+ category: str
64
+ image: Image.Image
65
+ sha256: str
66
+ ai_allowed: bool
67
+ hint: str # size and frames, for the prompt
68
+
69
+
70
+ def library_items(root: Path, packs: list[str] | None = None) -> list[Item]:
71
+ """Every asset (an animation sheet as its first frame) and every named tile of the
72
+ installed tile sets, in a stable order."""
73
+ from spriteforge.game.tilesets import load_tilesets, tile_image
74
+
75
+ items = []
76
+ installed = {p.id: p for p in load_packs(root) if not packs or p.id in packs}
77
+ for pack in installed.values():
78
+ for a in pack.assets:
79
+ asset = a.model_dump(mode="json")
80
+ frames = f", {a.frames} frames of {a.cell[0]}x{a.cell[1]}" if a.frames > 1 else ""
81
+ items.append(Item(a.id, "assets", pack.id, a.name, a.category,
82
+ asset_image(asset, root), a.sha256, pack.ai_allowed,
83
+ f"{a.size[0]}x{a.size[1]}{frames}")) # fmt: skip
84
+ for ts in load_tilesets():
85
+ if ts["pack"] not in installed:
86
+ continue
87
+ for name, spec in ts["tiles"].items():
88
+ tile = tile_image(ts, name)
89
+ buf = io.BytesIO()
90
+ tile.save(buf, format="PNG")
91
+ items.append(Item(f"{ts['id']}/{name}", "tiles", ts["pack"], spec["what"], "tile",
92
+ tile, hashlib.sha256(buf.getvalue()).hexdigest(),
93
+ installed[ts["pack"]].ai_allowed,
94
+ f"one {ts['tile_size']} px tile of a {ts['camera']} tile set")) # fmt: skip
95
+ return items
96
+
97
+
98
+ def measured_entry(item: Item, look: Look) -> dict:
99
+ return {
100
+ "sha256": item.sha256,
101
+ "what": item.name,
102
+ "text": f"{item.name} ({item.category}); {look.text}",
103
+ "view": "unknown",
104
+ "look": look.model_dump(),
105
+ "by": "measured",
106
+ "status": "measured",
107
+ }
108
+
109
+
110
+ def contact_sheet(items: list[Item]) -> bytes:
111
+ """Numbered cells of CELL px on a neutral grey; pixel art scaled by whole numbers."""
112
+ cols = min(4, len(items))
113
+ rows = math.ceil(len(items) / cols)
114
+ step = CELL + LABEL
115
+ sheet = Image.new("RGBA", (cols * CELL, rows * step), SHEET_BACKGROUND)
116
+ draw = ImageDraw.Draw(sheet)
117
+ font = ImageFont.load_default(size=LABEL - 4)
118
+ for n, item in enumerate(items):
119
+ image = item.image
120
+ if bbox := image.getchannel("A").getbbox():
121
+ image = image.crop(bbox)
122
+ side = max(image.size)
123
+ if side > CELL - 8:
124
+ k = math.ceil(side / (CELL - 8))
125
+ image = image.resize(
126
+ (max(1, image.width // k), max(1, image.height // k)), Image.Resampling.NEAREST
127
+ )
128
+ else:
129
+ k = max(1, (CELL - 8) // side)
130
+ image = image.resize((image.width * k, image.height * k), Image.Resampling.NEAREST)
131
+ x, y = (n % cols) * CELL, (n // cols) * step
132
+ draw.text((x + 4, y + 1), str(n + 1), fill=(255, 255, 255, 255), font=font)
133
+ sheet.alpha_composite(
134
+ image, (x + (CELL - image.width) // 2, y + LABEL + (CELL - image.height) // 2)
135
+ )
136
+ buf = io.BytesIO()
137
+ sheet.save(buf, format="PNG")
138
+ return buf.getvalue()
139
+
140
+
141
+ def sheet_prompt(items: list[Item]) -> str:
142
+ listed = "\n".join(
143
+ f'{n}. "{item.name}" ({item.category}, {item.hint})' for n, item in enumerate(items, 1)
144
+ )
145
+ return (
146
+ f"The image is a contact sheet of {len(items)} numbered pixel-art images from a game "
147
+ "art library, each enlarged with square pixels on a neutral grey (an animation sheet "
148
+ "shows its first frame; a tile set shows its whole sheet). For every number, say what "
149
+ "the image shows, judged from its pixels: `what` in 1-4 words, `description` in one "
150
+ "sentence of at most 30 words (the subject, its main colours by name and whether it is "
151
+ "light or dark, and the details that matter when choosing art for a game), and `view` "
152
+ "(the camera it is drawn for). The names below come from file names: they are hints "
153
+ "and may be wrong or cryptic; trust the pixels.\n\n" + listed
154
+ )
155
+
156
+
157
+ def role_text() -> str:
158
+ from spriteforge.agents.roles import load_roles
159
+
160
+ r = load_roles()["Librarian"]
161
+ return f"You are the {r.name} of a small game studio. Goal: {r.goal}\n{r.prompt}"
162
+
163
+
164
+ def describe_library(
165
+ root: Path | None = None,
166
+ provider: LLMProvider | None = None,
167
+ *,
168
+ model: str = "",
169
+ reasoning: str | None = None,
170
+ packs: list[str] | None = None,
171
+ force: bool = False,
172
+ per_sheet: int = PER_SHEET,
173
+ log: Callable[[str], None] = print,
174
+ ) -> dict:
175
+ """Describe the library (see the module doc). Without a `provider` only measured looks
176
+ are written. A failed sheet keeps measured entries for its images; running out of budget
177
+ stops the model calls. Returns counts."""
178
+ root = library_root(root)
179
+ old = load_descriptions(root)
180
+ items = library_items(root, packs)
181
+ out: dict[str, dict] = {p: {"assets": {}, "tiles": {}} for p in {i.pack for i in items}}
182
+ for pack, data in old.items(): # packs not described this time keep their file as is
183
+ out.setdefault(pack, data)
184
+ todo: list[Item] = []
185
+ counts = {"model": 0, "measured": 0, "kept": 0, "failed": 0, "sheets": 0}
186
+ for item in items:
187
+ look = measure(item.image)
188
+ prev = old.get(item.pack, {}).get(item.section, {}).get(item.key)
189
+ same = prev is not None and prev.get("sha256") == item.sha256
190
+ if same and (prev["status"] == "reviewed" or (prev["by"] != "measured" and not force)):
191
+ out[item.pack][item.section][item.key] = {**prev, "look": look.model_dump()}
192
+ counts["kept"] += 1
193
+ continue
194
+ out[item.pack][item.section][item.key] = measured_entry(item, look)
195
+ if item.ai_allowed and provider is not None:
196
+ todo.append(item)
197
+ else:
198
+ counts["measured"] += 1
199
+ batches = [todo[i : i + per_sheet] for i in range(0, len(todo), per_sheet)]
200
+ for number, batch in enumerate(batches, 1):
201
+ request = ChatRequest(
202
+ messages=[
203
+ Message(role="system", content=role_text()),
204
+ Message(role="user", content=sheet_prompt(batch), images=[contact_sheet(batch)]),
205
+ ],
206
+ reasoning_effort=reasoning,
207
+ cache_key="spriteforge-describe",
208
+ )
209
+ try:
210
+ counts["sheets"] += 1
211
+ answer = chat_json(provider, request, SheetAnswer)
212
+ except BudgetExceeded as exc:
213
+ log(f" sheet {number}/{len(batches)}: {exc}; the rest keep measured entries")
214
+ counts["failed"] += sum(len(b) for b in batches[number - 1 :])
215
+ break
216
+ except Exception as exc: # noqa: BLE001 -- one bad sheet must not lose the others
217
+ log(f" sheet {number}/{len(batches)} failed ({type(exc).__name__}: {exc})"[:300])
218
+ counts["failed"] += len(batch)
219
+ continue
220
+ seen = {s.n: s for s in answer.images if 1 <= s.n <= len(batch)}
221
+ for n, item in enumerate(batch, 1):
222
+ if n not in seen:
223
+ counts["failed"] += 1
224
+ continue
225
+ s = seen[n]
226
+ entry = out[item.pack][item.section][item.key]
227
+ entry.update(what=s.what.strip(), text=s.description.strip(), view=s.view,
228
+ by=model or "model", status="generated") # fmt: skip
229
+ counts["model"] += 1
230
+ log(f" sheet {number}/{len(batches)}: {len(seen)} of {len(batch)} described")
231
+ folder = root / "descriptions"
232
+ folder.mkdir(parents=True, exist_ok=True)
233
+ for pack, data in sorted(out.items()):
234
+ if not data.get("assets") and not data.get("tiles"):
235
+ continue
236
+ data = {
237
+ "pack": pack,
238
+ **{k: dict(sorted(v.items())) for k, v in data.items() if k != "pack"},
239
+ }
240
+ (folder / f"{pack}.json").write_text(json.dumps(data, indent=1) + "\n", encoding="utf-8")
241
+ rebuild_index(root)
242
+ return counts
@@ -0,0 +1,154 @@
1
+ """What a library image looks like, measured from its pixels (free; no model sees it).
2
+
3
+ `measure` names the main colours and gives brightness (mean Rec. 709 luma of the drawn
4
+ pixels, 0-1) and detail (the spread between the 10th and 90th percentile of that luma). The
5
+ library descriptions carry it for every asset, the Art Director's list shows it, and the art
6
+ step compares library picks with the colours a design names (`colour_gap`).
7
+ """
8
+
9
+ import colorsys
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ from PIL import Image
14
+ from pydantic import BaseModel
15
+
16
+ LUMA = np.array([0.2126, 0.7152, 0.0722])
17
+ FLAT = 0.03 # detail under this: one flat colour (e.g. a wall top with no bricks)
18
+
19
+
20
+ class Colour(BaseModel):
21
+ name: str
22
+ hex: str
23
+ share: float # of the drawn pixels
24
+
25
+
26
+ class Look(BaseModel):
27
+ colours: list[Colour] # main colours, largest first
28
+ brightness: float
29
+ detail: float
30
+ fill: float # drawn share of the image
31
+ mean: str # mean colour of the drawn pixels
32
+ text: str # "tan and brown; mid (0.49); low-contrast detail"
33
+
34
+
35
+ def hex_of(rgb) -> str:
36
+ return "#" + "".join(f"{int(np.rint(c)):02X}" for c in rgb[:3])
37
+
38
+
39
+ def rgb_of(hex_code: str) -> tuple[int, int, int]:
40
+ return tuple(int(hex_code[i : i + 2], 16) for i in (1, 3, 5))
41
+
42
+
43
+ def colour_name(rgb) -> str:
44
+ """A plain colour word for an RGB colour: 'tan', 'dark grey', 'grey-teal', 'olive' ..."""
45
+ h, s, v = colorsys.rgb_to_hsv(*(c / 255 for c in rgb[:3]))
46
+ if v < 0.13:
47
+ return "black"
48
+ if s < 0.13 or (s < 0.2 and v < 0.3):
49
+ return (
50
+ "dark grey" if v < 0.4 else "grey" if v < 0.68 else "light grey" if v < 0.9 else "white"
51
+ )
52
+ deg = h * 360
53
+ if deg < 15 or deg >= 345:
54
+ base = "red"
55
+ elif deg < 42:
56
+ base = "tan" if s < 0.45 and v >= 0.45 else "brown" if v < 0.62 else "orange"
57
+ elif deg < 70:
58
+ base = "olive" if v < 0.55 else "cream" if s < 0.4 else "yellow"
59
+ elif deg < 160:
60
+ base = "green"
61
+ elif deg < 195:
62
+ base = "cyan" if v > 0.75 and s > 0.45 else "teal"
63
+ elif deg < 255:
64
+ base = "blue"
65
+ elif deg < 290:
66
+ base = "purple"
67
+ else:
68
+ base = "pink" if v > 0.75 and s < 0.55 else "magenta"
69
+ earthy = base in {"tan", "brown", "olive", "cream"}
70
+ if s < 0.3 and not earthy:
71
+ base = f"grey-{base}"
72
+ if v < 0.38 and base not in {"brown", "olive"}:
73
+ return f"dark {base}"
74
+ if v > 0.88 and s < 0.5 and not earthy:
75
+ return f"light {base}"
76
+ return base
77
+
78
+
79
+ def brightness_word(value: float) -> str:
80
+ for limit, word in ((0.2, "very dark"), (0.38, "dark"), (0.6, "mid"), (0.8, "light")):
81
+ if value < limit:
82
+ return word
83
+ return "very light"
84
+
85
+
86
+ def detail_word(value: float) -> str:
87
+ if value < FLAT:
88
+ return "flat, no detail"
89
+ return "low-contrast detail" if value < 0.12 else "high-contrast detail"
90
+
91
+
92
+ def measure(image: Image.Image) -> Look:
93
+ a = np.asarray(image.convert("RGBA"))
94
+ drawn = a[..., 3] > 127
95
+ rgb = a[drawn][:, :3]
96
+ if not len(rgb):
97
+ return Look(colours=[], brightness=0, detail=0, fill=0, mean="#000000", text="empty")
98
+ luma = rgb.astype(float) @ LUMA / 255
99
+ colours, counts = np.unique(rgb, axis=0, return_counts=True)
100
+ groups: dict[str, list] = {}
101
+ for colour, count in zip(colours, counts, strict=True):
102
+ groups.setdefault(colour_name(colour), []).append((colour.astype(float), int(count)))
103
+ total = int(counts.sum())
104
+ named = []
105
+ for name, items in groups.items():
106
+ n = sum(k for _, k in items)
107
+ mean = sum(c * k for c, k in items) / n
108
+ named.append(Colour(name=name, hex=hex_of(mean), share=round(n / total, 3)))
109
+ named.sort(key=lambda c: (-c.share, c.name))
110
+ main = [c for c in named if c.share >= 0.08][:4] or named[:1]
111
+ words = [c.name for c in main]
112
+ listed = ", ".join(words[:-1]) + " and " + words[-1] if len(words) > 1 else words[0]
113
+ brightness = round(float(luma.mean()), 3)
114
+ detail = round(float(np.percentile(luma, 90) - np.percentile(luma, 10)), 3)
115
+ return Look(
116
+ colours=main,
117
+ brightness=brightness,
118
+ detail=detail,
119
+ fill=round(float(drawn.mean()), 3),
120
+ mean=hex_of(rgb.astype(float).mean(axis=0)),
121
+ text=f"{listed}; {brightness_word(brightness)} ({brightness:.2f}); {detail_word(detail)}",
122
+ )
123
+
124
+
125
+ def lab(hex_code: str) -> np.ndarray:
126
+ """CIELAB (D65) of an sRGB colour."""
127
+ rgb = np.array(rgb_of(hex_code)) / 255
128
+ linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
129
+ xyz = (
130
+ np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]])
131
+ @ linear
132
+ / np.array([0.95047, 1.0, 1.08883])
133
+ )
134
+ f = np.where(xyz > (6 / 29) ** 3, np.cbrt(xyz), xyz / (3 * (6 / 29) ** 2) + 4 / 29)
135
+ return np.array([116 * f[1] - 16, 500 * (f[0] - f[1]), 200 * (f[1] - f[2])])
136
+
137
+
138
+ def colour_gap(a: str, b: str) -> float:
139
+ """How far apart two colours look (CIE76 delta E; about 25 and up: clearly different)."""
140
+ return float(np.linalg.norm(lab(a) - lab(b)))
141
+
142
+
143
+ def asset_image(asset: dict, root: Path) -> Image.Image:
144
+ """What an asset shows: its first drawn frame for an animation sheet, else the image."""
145
+ image = Image.open(root / asset["file"]).convert("RGBA")
146
+ w, h = asset["cell"]
147
+ if asset.get("frames", 1) <= 1 or (w, h) == image.size:
148
+ return image
149
+ for y in range(0, image.height - h + 1, h):
150
+ for x in range(0, image.width - w + 1, w):
151
+ frame = image.crop((x, y, x + w, y + h))
152
+ if frame.getchannel("A").getbbox():
153
+ return frame
154
+ return image.crop((0, 0, w, h))
@@ -0,0 +1,79 @@
1
+ """Asset library records: every third-party file carries its pack's source and license."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ # Licenses that allow shipping the art inside a generated game without extra steps.
8
+ SHIPPABLE = {"CC0", "CC0-1.0", "PUBLIC-DOMAIN", "OWN"}
9
+ # Free-to-use custom licenses: fine inside a built game, but the raw files may not be
10
+ # redistributed (kept out of git via `redistributable: false`).
11
+ FREE_USE = {"FREE-USE"}
12
+ # Licenses that allow shipping only with attribution (credits file is written into the build).
13
+ ATTRIBUTION = {"CC-BY-3.0", "CC-BY-4.0", "OGA-BY-3.0", "MIT"}
14
+
15
+ Category = Literal[
16
+ "character",
17
+ "enemy",
18
+ "tileset",
19
+ "prop",
20
+ "item",
21
+ "equipment",
22
+ "ui",
23
+ "effect",
24
+ "background",
25
+ "other",
26
+ ]
27
+
28
+
29
+ class Strict(BaseModel):
30
+ model_config = ConfigDict(extra="forbid")
31
+
32
+
33
+ class AssetEntry(Strict):
34
+ id: str # "<pack>/<slug>"
35
+ file: str # path relative to the library root
36
+ category: Category
37
+ subcategory: str = ""
38
+ name: str
39
+ tags: list[str] = Field(default_factory=list)
40
+ size: tuple[int, int]
41
+ # Grid cell when the file is a sheet; equals size for a single sprite.
42
+ cell: tuple[int, int]
43
+ frames: int = 1
44
+ colors: int
45
+ opaque_bbox: tuple[int, int, int, int] | None
46
+ transparent_background: bool
47
+ sha256: str
48
+
49
+
50
+ class Pack(Strict):
51
+ id: str = Field(pattern=r"^[a-z0-9][a-z0-9-]*$")
52
+ title: str
53
+ author: str = "unknown"
54
+ source_url: str = ""
55
+ license: str = "UNKNOWN"
56
+ license_url: str = ""
57
+ license_verified: bool = False
58
+ notes: str = ""
59
+ original_archive: str = ""
60
+ # False: raw files may not be shared (public repo, zip of the library); builds still may.
61
+ redistributable: bool = True
62
+ # False: the license forbids AI use (e.g. "no AI training"); never send to Muse as a
63
+ # reference or training input. Builds may still include the art.
64
+ ai_allowed: bool = True
65
+ # Which way side-facing sprites in this pack look. Kits expect right-facing art and flip
66
+ # it when moving left, so "left" packs are mirrored when they are adapted into a slot.
67
+ faces: Literal["right", "left"] = "right"
68
+ assets: list[AssetEntry] = Field(default_factory=list)
69
+
70
+ @property
71
+ def usage(self) -> Literal["ship", "ship-with-credit", "reference-only"]:
72
+ """What a build may do with this pack. Unverified packs are never shipped."""
73
+ if not self.license_verified:
74
+ return "reference-only"
75
+ if self.license.upper() in SHIPPABLE | FREE_USE:
76
+ return "ship"
77
+ if self.license.upper() in ATTRIBUTION:
78
+ return "ship-with-credit"
79
+ return "reference-only"