spritehatch-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SpriteHatch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: spritehatch-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for SpriteHatch: AI agents generate 2D game sprites, animations, effects, items, UI, scenes and tilesets and export them for Godot, Unity, GameMaker and RPG Maker.
5
+ Author: SpriteHatch
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://spritehatch.com
8
+ Project-URL: Documentation, https://spritehatch.com/docs/mcp
9
+ Keywords: mcp,model-context-protocol,ai-agents,game-development,sprites,pixel-art,animation,gamedev
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Games/Entertainment
15
+ Classifier: Topic :: Multimedia :: Graphics
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: mcp<3,>=2.3
20
+ Requires-Dist: pillow>=10
21
+ Dynamic: license-file
22
+
23
+ # SpriteHatch MCP
24
+
25
+ Lets AI agents (Claude Code, Cursor, Codex, Windsurf…) make 2D game assets with [SpriteHatch](https://spritehatch.com)
26
+ and bring them into the game: characters, monsters, animations, abilities with separate effect layers, effects, items,
27
+ UI, parallax scenes and tilesets, exported for Godot, Unity, GameMaker, RPG Maker or plain sprite sheets.
28
+
29
+ The server teaches the agent which tool fits each need and guards the user's credits:
30
+
31
+ - `spritehatch_guide` (free) turns a game need ("the dragon breathes fire at the player") into the right recipe, with
32
+ costs and pitfalls. The same decision rules are sent to the agent when it connects.
33
+ - Quality, art style and image engine are the user's choice: when the request does not say, the tool returns the
34
+ options (with costs) for the agent to ask, or shows a form in clients that support it. Image engines are known only by
35
+ their product names.
36
+ - Effects that stay on the body (a charge-up) are drawn into the character animation; what flies to a target becomes
37
+ separate effects the game moves. The animation plan is shown exactly as it will be generated (prompt, ending, beats)
38
+ and can be corrected before paying.
39
+ - Session budget and approval: spending above the approval limit since the last approval asks the user (MCP elicitation,
40
+ or a one-time `confirm_token` the agent only sends after asking), so splitting a request does not get around it. A
41
+ prompt the client declines on its own is not taken as the user's answer. Failed jobs are refunded and give the budget
42
+ back; the same request failing twice stops.
43
+ - Effects are checked for frames cut by the canvas edge and empty frames; a bad ending can be redone from a frame.
44
+ - Animations are judged for free after generation; the agent may improve one at most once on its own, then shows the
45
+ user.
46
+ - Results come back as preview images, and exports come with a manifest (frame size, pivot, clips, ability timeline)
47
+ the agent can wire into code.
48
+
49
+ Requires a SpriteHatch API key (Pro or Studio plan): app → avatar → **My account and statement** → **API keys**.
50
+
51
+ ## Local (stdio): files go straight into your game project
52
+
53
+ ```sh
54
+ # Claude Code
55
+ claude mcp add spritehatch -e SPRITEHATCH_API_KEY=sg_live_... -- uvx spritehatch-mcp
56
+ ```
57
+
58
+ Other clients (Cursor, Windsurf, Codex…), in their MCP config:
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "spritehatch": {
64
+ "command": "uvx",
65
+ "args": ["spritehatch-mcp"],
66
+ "env": { "SPRITEHATCH_API_KEY": "sg_live_..." }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ | Variable | Default | |
73
+ |---|---|---|
74
+ | `SPRITEHATCH_API_KEY` | — | Required. |
75
+ | `SPRITEHATCH_BUDGET` | `300` | Credits this session may spend. |
76
+ | `SPRITEHATCH_CONFIRM_ABOVE` | `60` | Single requests above this need the user's approval. `0` = always ask. |
77
+ | `SPRITEHATCH_EXPORT_DIR` | `spritehatch-assets` | Where `export_asset` writes when no folder is given. |
78
+ | `SPRITEHATCH_URL` | `https://spritehatch.com` | API address. |
79
+
80
+ ## Hosted (streamable HTTP): nothing to install
81
+
82
+ ```sh
83
+ claude mcp add --transport http spritehatch https://spritehatch.com/mcp \
84
+ --header "Authorization: Bearer sg_live_..." --header "X-SpriteHatch-Budget: 300"
85
+ ```
86
+
87
+ Optional headers: `X-SpriteHatch-Budget`, `X-SpriteHatch-Confirm-Above`. The hosted server cannot read or write the
88
+ agent's disk: reference images go as https URLs or base64, and exports come back as 30-minute download links.
89
+
90
+ Run it yourself: `spritehatch-mcp --http --host 0.0.0.0 --port 7870` with `SPRITEHATCH_URL` pointing at the API and
91
+ `SPRITEHATCH_MCP_PUBLIC_URL` set to this server's public address (for the download links). Health check: `GET /health`.
92
+
93
+ ## Tools
94
+
95
+ | Need | Tool |
96
+ |---|---|
97
+ | Not sure what to make | `spritehatch_guide` (free) |
98
+ | Account, credits, session budget | `get_account` |
99
+ | Projects and style kit (pixel art, height, 2/4/8 directions, palette) | `list_projects`, `setup_project` |
100
+ | What already exists | `list_assets`, `get_asset`, `get_preview` |
101
+ | New character/monster, from text or an image | `create_character`, `choose_candidate` |
102
+ | Outfit, color, armor, elite version, rarity | `add_skin` |
103
+ | Top-down / isometric views | `add_directions` |
104
+ | Body animation (effects on the body included) | `plan_animation` (free) → `generate_animation`, `review_animation` (free), `improve_animation` |
105
+ | Attack or power that emits something | charge animation + `create_vfx` (best control), or `create_ability` (automatic) |
106
+ | Standalone effect, projectile, impact | `create_vfx`, `improve_vfx` |
107
+ | Pick which kept version is used | `use_version` |
108
+ | Item, weapon, prop, icon | `create_item` |
109
+ | Buttons, bars, panels | `create_ui`, `add_ui_states` |
110
+ | Side-scroller background | `create_scene`, `add_parallax_layers` |
111
+ | Tilesets / maps | `create_tileset` |
112
+ | Fix a detail | `edit_asset`, `apply_edit` |
113
+ | Wait for generation | `wait_for_jobs` |
114
+ | Take it to the game | `export_asset` |
115
+
116
+ Resource `spritehatch://guide` has the full guide; prompts `make_game_character` and `add_attack_with_effect` start the
117
+ two most common flows.
118
+
119
+ ## Development
120
+
121
+ Tests live in the main repository: `.venv/bin/python scripts/check_mcp.py` runs the real API, queue, billing and
122
+ exports with a simulated GPU, through both transports.
@@ -0,0 +1,100 @@
1
+ # SpriteHatch MCP
2
+
3
+ Lets AI agents (Claude Code, Cursor, Codex, Windsurf…) make 2D game assets with [SpriteHatch](https://spritehatch.com)
4
+ and bring them into the game: characters, monsters, animations, abilities with separate effect layers, effects, items,
5
+ UI, parallax scenes and tilesets, exported for Godot, Unity, GameMaker, RPG Maker or plain sprite sheets.
6
+
7
+ The server teaches the agent which tool fits each need and guards the user's credits:
8
+
9
+ - `spritehatch_guide` (free) turns a game need ("the dragon breathes fire at the player") into the right recipe, with
10
+ costs and pitfalls. The same decision rules are sent to the agent when it connects.
11
+ - Quality, art style and image engine are the user's choice: when the request does not say, the tool returns the
12
+ options (with costs) for the agent to ask, or shows a form in clients that support it. Image engines are known only by
13
+ their product names.
14
+ - Effects that stay on the body (a charge-up) are drawn into the character animation; what flies to a target becomes
15
+ separate effects the game moves. The animation plan is shown exactly as it will be generated (prompt, ending, beats)
16
+ and can be corrected before paying.
17
+ - Session budget and approval: spending above the approval limit since the last approval asks the user (MCP elicitation,
18
+ or a one-time `confirm_token` the agent only sends after asking), so splitting a request does not get around it. A
19
+ prompt the client declines on its own is not taken as the user's answer. Failed jobs are refunded and give the budget
20
+ back; the same request failing twice stops.
21
+ - Effects are checked for frames cut by the canvas edge and empty frames; a bad ending can be redone from a frame.
22
+ - Animations are judged for free after generation; the agent may improve one at most once on its own, then shows the
23
+ user.
24
+ - Results come back as preview images, and exports come with a manifest (frame size, pivot, clips, ability timeline)
25
+ the agent can wire into code.
26
+
27
+ Requires a SpriteHatch API key (Pro or Studio plan): app → avatar → **My account and statement** → **API keys**.
28
+
29
+ ## Local (stdio): files go straight into your game project
30
+
31
+ ```sh
32
+ # Claude Code
33
+ claude mcp add spritehatch -e SPRITEHATCH_API_KEY=sg_live_... -- uvx spritehatch-mcp
34
+ ```
35
+
36
+ Other clients (Cursor, Windsurf, Codex…), in their MCP config:
37
+
38
+ ```json
39
+ {
40
+ "mcpServers": {
41
+ "spritehatch": {
42
+ "command": "uvx",
43
+ "args": ["spritehatch-mcp"],
44
+ "env": { "SPRITEHATCH_API_KEY": "sg_live_..." }
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ | Variable | Default | |
51
+ |---|---|---|
52
+ | `SPRITEHATCH_API_KEY` | — | Required. |
53
+ | `SPRITEHATCH_BUDGET` | `300` | Credits this session may spend. |
54
+ | `SPRITEHATCH_CONFIRM_ABOVE` | `60` | Single requests above this need the user's approval. `0` = always ask. |
55
+ | `SPRITEHATCH_EXPORT_DIR` | `spritehatch-assets` | Where `export_asset` writes when no folder is given. |
56
+ | `SPRITEHATCH_URL` | `https://spritehatch.com` | API address. |
57
+
58
+ ## Hosted (streamable HTTP): nothing to install
59
+
60
+ ```sh
61
+ claude mcp add --transport http spritehatch https://spritehatch.com/mcp \
62
+ --header "Authorization: Bearer sg_live_..." --header "X-SpriteHatch-Budget: 300"
63
+ ```
64
+
65
+ Optional headers: `X-SpriteHatch-Budget`, `X-SpriteHatch-Confirm-Above`. The hosted server cannot read or write the
66
+ agent's disk: reference images go as https URLs or base64, and exports come back as 30-minute download links.
67
+
68
+ Run it yourself: `spritehatch-mcp --http --host 0.0.0.0 --port 7870` with `SPRITEHATCH_URL` pointing at the API and
69
+ `SPRITEHATCH_MCP_PUBLIC_URL` set to this server's public address (for the download links). Health check: `GET /health`.
70
+
71
+ ## Tools
72
+
73
+ | Need | Tool |
74
+ |---|---|
75
+ | Not sure what to make | `spritehatch_guide` (free) |
76
+ | Account, credits, session budget | `get_account` |
77
+ | Projects and style kit (pixel art, height, 2/4/8 directions, palette) | `list_projects`, `setup_project` |
78
+ | What already exists | `list_assets`, `get_asset`, `get_preview` |
79
+ | New character/monster, from text or an image | `create_character`, `choose_candidate` |
80
+ | Outfit, color, armor, elite version, rarity | `add_skin` |
81
+ | Top-down / isometric views | `add_directions` |
82
+ | Body animation (effects on the body included) | `plan_animation` (free) → `generate_animation`, `review_animation` (free), `improve_animation` |
83
+ | Attack or power that emits something | charge animation + `create_vfx` (best control), or `create_ability` (automatic) |
84
+ | Standalone effect, projectile, impact | `create_vfx`, `improve_vfx` |
85
+ | Pick which kept version is used | `use_version` |
86
+ | Item, weapon, prop, icon | `create_item` |
87
+ | Buttons, bars, panels | `create_ui`, `add_ui_states` |
88
+ | Side-scroller background | `create_scene`, `add_parallax_layers` |
89
+ | Tilesets / maps | `create_tileset` |
90
+ | Fix a detail | `edit_asset`, `apply_edit` |
91
+ | Wait for generation | `wait_for_jobs` |
92
+ | Take it to the game | `export_asset` |
93
+
94
+ Resource `spritehatch://guide` has the full guide; prompts `make_game_character` and `add_attack_with_effect` start the
95
+ two most common flows.
96
+
97
+ ## Development
98
+
99
+ Tests live in the main repository: `.venv/bin/python scripts/check_mcp.py` runs the real API, queue, billing and
100
+ exports with a simulated GPU, through both transports.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spritehatch-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for SpriteHatch: AI agents generate 2D game sprites, animations, effects, items, UI, scenes and tilesets and export them for Godot, Unity, GameMaker and RPG Maker."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "SpriteHatch" }]
13
+ requires-python = ">=3.11"
14
+ dependencies = ["mcp>=2.3,<3", "pillow>=10"]
15
+ keywords = ["mcp", "model-context-protocol", "ai-agents", "game-development", "sprites", "pixel-art", "animation", "gamedev"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Games/Entertainment",
22
+ "Topic :: Multimedia :: Graphics",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://spritehatch.com"
27
+ Documentation = "https://spritehatch.com/docs/mcp"
28
+
29
+ [project.scripts]
30
+ spritehatch-mcp = "spritehatch_mcp.__main__:main"
31
+
32
+ [tool.setuptools]
33
+ packages = ["spritehatch_mcp"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """SpriteHatch MCP server: lets AI agents generate 2D game assets with SpriteHatch and bring them into the game."""
2
+ __version__ = '0.1.0'
@@ -0,0 +1,26 @@
1
+ """spritehatch-mcp → local server over stdio (Claude Code, Cursor, Codex…); needs SPRITEHATCH_API_KEY.
2
+ spritehatch-mcp --http → hosted server (streamable HTTP); each request brings its key in the Authorization header."""
3
+ import argparse
4
+ import os
5
+
6
+ from .server import Config, build
7
+
8
+
9
+ def main() -> None:
10
+ parser = argparse.ArgumentParser(prog='spritehatch-mcp', description='SpriteHatch MCP server')
11
+ parser.add_argument('--http', action='store_true', help='serve over streamable HTTP (hosted mode) instead of stdio')
12
+ parser.add_argument('--host', default=os.environ.get('SPRITEHATCH_MCP_HOST', '127.0.0.1'))
13
+ parser.add_argument('--port', type=int, default=int(os.environ.get('SPRITEHATCH_MCP_PORT', '7870')))
14
+ args = parser.parse_args()
15
+ if args.http:
16
+ config = Config.from_env('hosted')
17
+ build(config).run('streamable-http', host=args.host, port=args.port, streamable_http_path='/mcp')
18
+ else:
19
+ config = Config.from_env('local')
20
+ if not config.api_key:
21
+ parser.exit(2, 'SPRITEHATCH_API_KEY is not set. Create a key in SpriteHatch > My account > API keys.\n')
22
+ build(config).run('stdio')
23
+
24
+
25
+ if __name__ == '__main__':
26
+ main()
@@ -0,0 +1,84 @@
1
+ """Async client for the SpriteHatch HTTP API (the same API documented at /docs/api).
2
+
3
+ Every call carries the customer's API key (`Authorization: Bearer sg_live_…`), so ownership, plan limits (API = Pro and
4
+ Studio) and credit charging stay where they already are: in the SpriteHatch server. This module only translates HTTP
5
+ failures into errors an agent can act on.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import httpx2 as httpx # the HTTP client the MCP SDK already ships (httpx fork)
10
+
11
+ # HTTP status → stable code + what the agent should do. The server's `detail` text is still in Portuguese.
12
+ ERRORS = {
13
+ 0: ('unreachable', 'Could not reach SpriteHatch. Check SPRITEHATCH_URL and the network, then try again.'),
14
+ 400: ('invalid_request', 'The request was rejected; fix the arguments described in `detail`.'),
15
+ 401: ('invalid_api_key', 'The API key is missing, wrong or deleted. The user creates one in SpriteHatch > My account > '
16
+ 'API keys and sets SPRITEHATCH_API_KEY (local) or the Authorization header (hosted).'),
17
+ 402: ('insufficient_credits', 'Not enough credits. Tell the user the cost and balance; do not retry.'),
18
+ 403: ('not_allowed', 'The plan does not include the API/MCP (Pro and Studio do), or this action needs the web app.'),
19
+ 404: ('not_found', 'The asset, project or job does not exist or belongs to another account. Use list_assets / list_projects.'),
20
+ 409: ('busy_or_changed', 'The asset is not ready yet, another generation on the same animation is running, or the '
21
+ 'version changed. Call wait_for_jobs, then try again.'),
22
+ 422: ('invalid_request', 'Some argument has the wrong type or is out of range.'),
23
+ 502: ('upstream_error', 'A payment/upstream service failed. Try again later.'),
24
+ 504: ('timeout', 'SpriteHatch took too long to answer. Try again; generation jobs keep running on the server.'),
25
+ 503: ('service_unavailable', 'A SpriteHatch service (vision planner or judge) is temporarily unavailable. Try again later.'),
26
+ }
27
+
28
+
29
+ class ApiError(Exception):
30
+ def __init__(self, status: int, detail: str):
31
+ self.status = status
32
+ self.detail = detail
33
+ self.code, self.hint = ERRORS.get(status, ('server_error', 'Unexpected server error. Try again later.'))
34
+ super().__init__(f'{self.code} ({status}): {detail}')
35
+
36
+ def as_dict(self) -> dict:
37
+ return {'error': self.code, 'status': self.status, 'detail': self.detail, 'what_to_do': self.hint}
38
+
39
+
40
+ class Api:
41
+ def __init__(self, base_url: str, key: str, timeout: float = 60.0):
42
+ if not key:
43
+ raise ApiError(401, 'Nenhuma chave de API configurada.')
44
+ self.base_url = base_url.rstrip('/')
45
+ self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, follow_redirects=True,
46
+ headers={'Authorization': f'Bearer {key}', 'User-Agent': 'spritehatch-mcp'})
47
+
48
+ async def __aenter__(self) -> Api:
49
+ return self
50
+
51
+ async def __aexit__(self, *_) -> None:
52
+ await self._client.aclose()
53
+
54
+ async def _send(self, method: str, path: str, **kwargs) -> httpx.Response:
55
+ try:
56
+ response = await self._client.request(method, path, **kwargs)
57
+ except httpx.TimeoutException:
58
+ raise ApiError(504, 'O servidor demorou para responder.')
59
+ except httpx.HTTPError as error:
60
+ raise ApiError(0, f'Não foi possível falar com o SpriteHatch em {self.base_url}: {error}')
61
+ if response.status_code >= 400:
62
+ try:
63
+ detail = response.json().get('detail')
64
+ except ValueError:
65
+ detail = None
66
+ if not isinstance(detail, str):
67
+ detail = str(detail or response.text[:300] or response.reason_phrase)
68
+ raise ApiError(response.status_code, detail)
69
+ return response
70
+
71
+ async def get(self, path: str, **params):
72
+ return (await self._send('GET', path, params={k: v for k, v in params.items() if v is not None})).json()
73
+
74
+ async def post(self, path: str, body: dict | None = None, *, data: dict | None = None, files: dict | None = None):
75
+ if files is not None:
76
+ return (await self._send('POST', path, data=data or {}, files=files)).json()
77
+ return (await self._send('POST', path, json=body or {})).json()
78
+
79
+ async def patch(self, path: str, body: dict):
80
+ return (await self._send('PATCH', path, json=body)).json()
81
+
82
+ async def get_bytes(self, path: str, **params) -> tuple[bytes, httpx.Headers]:
83
+ response = await self._send('GET', path, params={k: v for k, v in params.items() if v is not None})
84
+ return response.content, response.headers
@@ -0,0 +1,185 @@
1
+ """Files in and out: reference images the agent passes, and exports it takes home.
2
+
3
+ Local (stdio) mode reads images from disk and unpacks exports into the game project. Hosted mode cannot touch the
4
+ agent's disk, so it accepts URLs/base64 and serves each export from a short-lived link.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import base64
10
+ import binascii
11
+ import io
12
+ import ipaddress
13
+ import json
14
+ import re
15
+ import secrets
16
+ import socket
17
+ import time
18
+ import zipfile
19
+ from pathlib import Path
20
+ from urllib.parse import urlparse
21
+
22
+ import httpx2 as httpx # the HTTP client the MCP SDK already ships (httpx fork)
23
+
24
+ MAX_IMAGE = 15 * 1024 * 1024
25
+ LINK_TTL = 30 * 60
26
+
27
+
28
+ async def load_image(ref: str, allow_paths: bool) -> tuple[str, bytes]:
29
+ """`ref` is a local path (local mode only), an http(s) URL, a data URL or raw base64 → (filename, bytes)."""
30
+ ref = ref.strip()
31
+ if ref.startswith(('http://', 'https://')):
32
+ if not allow_paths: # hosted: never fetch the server's own network (SSRF); redirects are checked hop by hop
33
+ await _public_only(ref)
34
+ async with httpx.AsyncClient(timeout=30, follow_redirects=allow_paths) as client:
35
+ response = await client.get(ref)
36
+ hops = 0
37
+ while not allow_paths and response.is_redirect and hops < 3:
38
+ ref = str(response.next_request.url) if response.next_request else ''
39
+ await _public_only(ref)
40
+ response = await client.get(ref)
41
+ hops += 1
42
+ if response.status_code >= 400:
43
+ raise ValueError(f'Could not download the reference image ({response.status_code}).')
44
+ data, name = response.content, Path(ref.split('?')[0]).name or 'reference.png'
45
+ elif ref.startswith('data:'):
46
+ data, name = _b64(ref.split(',', 1)[-1]), 'reference.png'
47
+ elif allow_paths and (Path(ref).expanduser().exists()):
48
+ path = Path(ref).expanduser()
49
+ data, name = path.read_bytes(), path.name
50
+ elif re.fullmatch(r'[A-Za-z0-9+/=\s]{200,}', ref):
51
+ data, name = _b64(ref), 'reference.png'
52
+ elif allow_paths:
53
+ raise ValueError(f'Reference image not found: {ref}')
54
+ else:
55
+ raise ValueError('The hosted server cannot read local files: pass the image as an https URL or base64/data URL.')
56
+ if len(data) > MAX_IMAGE:
57
+ raise ValueError('The reference image is larger than 15 MB.')
58
+ return name, data
59
+
60
+
61
+ async def _public_only(url: str) -> None:
62
+ host = urlparse(url).hostname
63
+ if not host:
64
+ raise ValueError('Invalid image URL.')
65
+ try:
66
+ infos = await asyncio.get_running_loop().getaddrinfo(host, None)
67
+ except socket.gaierror:
68
+ raise ValueError(f'Could not resolve {host}.')
69
+ for info in infos:
70
+ address = ipaddress.ip_address(info[4][0])
71
+ if not address.is_global:
72
+ raise ValueError('The image URL must be a public address.')
73
+
74
+
75
+ def _b64(text: str) -> bytes:
76
+ try:
77
+ return base64.b64decode(re.sub(r'\s+', '', text), validate=True)
78
+ except (binascii.Error, ValueError):
79
+ raise ValueError('The reference image is not valid base64.')
80
+
81
+
82
+ def manifest(zip_bytes: bytes, target: str) -> dict:
83
+ """What the agent needs to wire the export into code, read from the files inside the zip."""
84
+ out: dict = {'files': [], 'clips': {}, 'notes': []}
85
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
86
+ names = [n for n in archive.namelist() if not n.endswith('/')]
87
+ out['files'] = names[:60] + ([f'... {len(names) - 60} more'] if len(names) > 60 else [])
88
+ for name in names:
89
+ if not name.endswith('.json'):
90
+ continue
91
+ try:
92
+ data = json.loads(archive.read(name))
93
+ except ValueError:
94
+ continue
95
+ if not isinstance(data, dict):
96
+ continue
97
+ meta = data.get('meta') if isinstance(data.get('meta'), dict) else None
98
+ if meta and 'clips' in meta: # character atlas (TexturePacker hash + clips)
99
+ out['atlas'] = name
100
+ out['image'] = meta.get('image')
101
+ out['cell'] = meta.get('cell')
102
+ out['pixel_art'] = bool(meta.get('pixel'))
103
+ out['clips'] = {k: {f: v.get(f) for f in ('animation', 'direction', 'fps', 'loop', 'frames')}
104
+ for k, v in meta['clips'].items()}
105
+ elif str(data.get('schema', '')).startswith('sprite-ability'):
106
+ out['ability'] = {k: data.get(k) for k in (
107
+ 'schema', 'name', 'mode', 'gesture', 'effects', 'anchors', 'release', 'speed', 'speed_units', 'effect_scale',
108
+ 'effect_scale_units', 'emission_lead_ms', 'emission_fade_ms', 'projectile_grow_ms', 'stream_duration_ms',
109
+ 'projectile_facing', 'anchor_space', 'target_height', 'cell') if data.get(k) is not None}
110
+ out['ability_file'] = name
111
+ elif 'frames' in data and 'blend' in data: # standalone effect
112
+ out['effect'] = {k: data.get(k) for k in ('name', 'frame_size', 'fps', 'loop', 'blend', 'pivot')}
113
+ out['effect']['frame_count'] = len(data.get('frames') or [])
114
+ elif name.endswith('tiles.json') or 'tiles' in data:
115
+ out['tileset'] = {k: data.get(k) for k in ('tile', 'tile_size', 'mode', 'columns', 'order', 'bits') if k in data}
116
+ elif 'layers' in data:
117
+ out['scene'] = {'layers': data.get('layers')}
118
+ for doc in ('COMPOSITION.txt', 'README.txt', 'STREAM.txt'):
119
+ hit = next((n for n in names if n.endswith(doc)), None)
120
+ if hit:
121
+ out['notes'].append({'file': hit, 'text': archive.read(hit).decode('utf-8', 'replace')[:1800]})
122
+ out['how_to_use'] = HOW_TO.get(target, HOW_TO['atlas'])
123
+ if 'ability' in out:
124
+ out['how_to_use'] += ' ' + HOW_TO['ability']
125
+ return out
126
+
127
+
128
+ HOW_TO = {
129
+ 'godot': 'Godot 4: the .tres is a SpriteFrames resource; assign it to an AnimatedSprite2D and play("<animation>_<direction>"). '
130
+ 'Offset the sprite so the pivot (cell.pivot, at the feet) sits on the node origin.',
131
+ 'unity': 'Unity: copy the folder into Assets/. The Editor/SpriteGeneratorImporter.cs script slices the sheet with the foot '
132
+ 'pivot and creates one AnimationClip per row (looping clips have loopTime on). Point filtering for pixel art.',
133
+ 'gamemaker': 'GameMaker: import each spr_*_stripN.png with "Import Strip Image"; N is the frame count. Set the origin '
134
+ 'to the pivot from the atlas JSON.',
135
+ 'rpgmaker': 'RPG Maker: copy $<name>.png into img/characters (3×4 walk sheet).',
136
+ 'atlas': 'Generic: <name>.png + <name>.json (TexturePacker hash). meta.clips lists every animation_direction with fps, '
137
+ 'loop and frame names; every frame has the same cell size and a normalized pivot at the feet.',
138
+ 'strips': 'One horizontal PNG strip per animation_direction; frame width = cell.width.',
139
+ 'frames': 'One PNG per frame, in folders per animation_direction.',
140
+ 'gif': 'GIF previews only (not for the game).',
141
+ 'ability': 'Ability: play the gesture; start the emission effect emission_lead_ms before gesture.release_frame at the '
142
+ 'anchor for the current direction (normalized in the gesture cell); at the release frame spawn the '
143
+ 'projectile (or stream) at effect_scale × target_height and move it at speed × target_height px/s facing '
144
+ 'right (mirror for W); on hit play impact once. Collision and damage are game code. See COMPOSITION.txt.',
145
+ }
146
+
147
+
148
+ class LinkStore:
149
+ """Hosted mode: exports are kept in memory for 30 minutes behind an unguessable link."""
150
+
151
+ def __init__(self):
152
+ self._files: dict[str, tuple[str, bytes, float]] = {}
153
+
154
+ def put(self, filename: str, data: bytes) -> str:
155
+ now = time.time()
156
+ self._files = {k: v for k, v in self._files.items() if v[2] > now}
157
+ while sum(len(v[1]) for v in self._files.values()) + len(data) > 256 * 1024 * 1024 and self._files:
158
+ self._files.pop(next(iter(self._files)))
159
+ token = secrets.token_urlsafe(24)
160
+ self._files[token] = (filename, data, now + LINK_TTL)
161
+ return token
162
+
163
+ def get(self, token: str) -> tuple[str, bytes] | None:
164
+ item = self._files.get(token)
165
+ if not item or item[2] < time.time():
166
+ return None
167
+ return item[0], item[1]
168
+
169
+
170
+ def unpack(zip_bytes: bytes, dest: Path) -> list[str]:
171
+ dest.mkdir(parents=True, exist_ok=True)
172
+ root = dest.resolve()
173
+ written = []
174
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
175
+ for member in archive.infolist():
176
+ target = (dest / member.filename).resolve()
177
+ if not target.is_relative_to(root): # zip slip
178
+ continue
179
+ if member.is_dir():
180
+ target.mkdir(parents=True, exist_ok=True)
181
+ continue
182
+ target.parent.mkdir(parents=True, exist_ok=True)
183
+ target.write_bytes(archive.read(member))
184
+ written.append(str(target))
185
+ return written