Add cloud sprite providers and browser demos

This commit is contained in:
Emil
2026-07-29 13:54:36 +03:00
parent 703fef0f87
commit 0e8b5d1fbc
16 changed files with 1113 additions and 9 deletions
+17
View File
@@ -0,0 +1,17 @@
# Copy this file to .env and set real values there. Never commit .env.
# Required only for cloud generation through provider="polza".
POLZA_API_KEY=
# Optional default cloud image model. It can be overridden per MCP tool call.
POLZA_IMAGE_MODEL=openai/gpt-image-1.5
# Required only for native pixel-art generation through provider="pixellab".
PIXELLAB_API_KEY=
# Optional local paths for the local Diffusers provider.
# IMAGEGEN_MODEL_DIR=/path/to/sdxl-base
# IMAGEGEN_LORA_DIR=/path/to/pixel-art-xl
# IMAGEGEN_LCM_LORA_DIR=/path/to/lcm-lora-sdxl
# IMAGEGEN_OUTPUT_DIR=/path/to/output
# IMAGEGEN_DB_PATH=/path/to/feedback.db
+1
View File
@@ -7,6 +7,7 @@ __pycache__/
*.pyc
*.pyo
*.egg-info/
.env
dist/
build/
+64
View File
@@ -13,6 +13,7 @@ MCP server for generating pixel-art sprites with transparent backgrounds. Bring
- **Reproducible** — optional seed for consistent results
- **Batch generation** — generate multiple sprites in one call
- **MCP integration** — works with any MCP-compatible client (opencode, Claude, etc.)
- **Cloud image models** — Polza.ai Media API, including image references and multi-image variations
- **Feedback loop** — rate generated sprites, AI uses high-rated ones as reference
## Quick Start
@@ -64,6 +65,52 @@ export IMAGEGEN_LORA_DIR=/path/to/your/lora # optional, set empty to disa
export IMAGEGEN_OUTPUT_DIR=/path/to/output
```
### Cloud generation with Polza.ai
No local GPU or model download is required when using the `polza` provider. Create
an API key in Polza.ai and expose it only to the MCP server process. The server
also loads a local `.env` file automatically; create it from the safe template:
```bash
cp .env.example .env
# Edit .env and set POLZA_API_KEY=your_key
# Or provide variables directly when starting the MCP server:
export POLZA_API_KEY=your_key
# Optional default; pass model per tool call to override it.
export POLZA_IMAGE_MODEL=openai/gpt-image-1.5
```
Use `generate_sprite` with `provider="polza"` for one image, or
`generate_images` for up to ten coherent variations in one API request. Both
accept `reference_images`: HTTPS URLs, data URIs, or local files. References are
sent to the provider so an LLM can retain a game's palette, outline treatment,
proportions, and character style across new sprites.
```json
{
"prompt": "a forest ranger facing left, idle game sprite",
"output_path": "rangers/idle.png",
"count": 4,
"model": "openai/gpt-image-1.5",
"reference_images": ["/assets/style-guide.png", "https://example.com/hero.png"],
"aspect_ratio": "1:1",
"remove_bg": true,
"pixel_size": 4
}
```
Set `wait=false` for a long-running generation and query its returned ID using
`get_generation_status`.
### Native pixel art with PixelLab
Set `PIXELLAB_API_KEY` in `.env` and pass `provider="pixellab"`. PixelLab is
specialised in game-ready pixel art; with `reference_images` it uses up to four
style references and generates style-consistent sprites. `generate_images` will
create one background job per requested variant, so `count` is reliable even
when a model does not offer a multi-image parameter.
### 4. Run as MCP server
```bash
@@ -123,6 +170,20 @@ Returns: `output_path`, `db_id`, `generation_time`, and other metadata.
Generate multiple sprites in one call. Each is saved to the feedback DB.
Each spec may also set `provider: "polza"`, `model`, `reference_images`,
`aspect_ratio`, `quality`, `count`, and `wait`.
#### `generate_images`
Generate 110 variants from one prompt through Polza.ai. It accepts the same
style-reference fields as cloud `generate_sprite` and saves every finished
variant to the feedback DB.
#### `get_generation_status`
Check a non-blocking Polza generation by its `generation_id` and retrieve its
status, output sources, usage, warnings, or error.
### Feedback
#### `rate_sprite`
@@ -167,6 +228,9 @@ Get database statistics: total sprites, rated, unrated, average rating.
| `IMAGEGEN_MODEL_DIR` | `~/models/flux2-klein-4b` | Path to base model |
| `IMAGEGEN_LORA_DIR` | `~/models/pixel-art-lora` | Path to LoRA adapter |
| `IMAGEGEN_OUTPUT_DIR` | `./output` | Default output directory |
| `POLZA_API_KEY` | — | Polza.ai API key; required for cloud generation |
| `POLZA_IMAGE_MODEL` | `openai/gpt-image-1.5` | Default Polza image model |
| `PIXELLAB_API_KEY` | — | PixelLab API key; required for `pixellab` provider |
### Swapping models
Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Imagen — Fleet Demo</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: #040816; color: #dbeeff; overflow: hidden; }
.scene {
isolation: isolate; position: relative; min-height: 100vh; overflow: hidden;
background: #07122a url("assets/space-nebula.png") center / cover no-repeat;
}
.scene::before { content: ""; position: absolute; inset: 0; z-index: -1; background: radial-gradient(circle at 45% 54%, transparent 0 23%, rgba(3,8,23,.54) 72%); }
.grain { position: absolute; inset: 0; pointer-events: none; opacity: .22; background-image: radial-gradient(#fff 0.65px, transparent .75px); background-size: 31px 31px; mix-blend-mode: screen; }
header { position: absolute; inset: 0 0 auto; display: flex; align-items: flex-start; justify-content: space-between; padding: clamp(20px,4vw,54px); text-shadow: 0 2px 16px #01030b; }
.eyebrow { margin: 0 0 8px; color: #76eaff; font-size: 11px; font-weight: 800; letter-spacing: .22em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(28px,5vw,70px); font-weight: 750; letter-spacing: -.055em; line-height: .92; }
.legend { padding: 11px 14px; border: 1px solid rgba(159,219,255,.28); border-radius: 999px; backdrop-filter: blur(10px); background: rgba(3,12,31,.4); color: #a9c7dd; font: 600 11px/1.2 ui-monospace, monospace; letter-spacing: .08em; }
.hud { position: absolute; left: clamp(20px,4vw,54px); bottom: clamp(20px,4vw,44px); max-width: 330px; padding: 18px; border-left: 2px solid #57dfff; background: linear-gradient(90deg,rgba(5,16,43,.78),transparent); }
.hud strong { display: block; margin-bottom: 6px; font-size: 13px; letter-spacing: .11em; text-transform: uppercase; }
.hud span { color: #aac4d9; font-size: 12px; line-height: 1.55; }
.fleet { position: absolute; inset: 13% 7% 8%; }
.ship { position: absolute; width: clamp(72px,12vw,180px); filter: drop-shadow(0 0 13px rgba(40,223,255,.55)); image-rendering: pixelated; transform-origin: center; animation: drift var(--duration, 9s) ease-in-out infinite alternate; }
.ship:hover { z-index: 4; filter: drop-shadow(0 0 24px #7cfcff); transform: scale(1.14); transition: transform .2s, filter .2s; }
.ship::after { content: ""; position: absolute; left: 43%; top: 78%; width: 14%; height: 45%; z-index: -1; opacity: .6; filter: blur(7px); background: linear-gradient(#7dffff,transparent); }
.flagship { --duration: 11s; left: 40%; top: 29%; width: clamp(135px,20vw,270px); animation-delay: -3s; }
.one { --duration: 8s; left: 9%; top: 35%; animation-delay: -5s; }
.two { --duration: 10s; right: 9%; top: 30%; animation-delay: -1s; }
.three { --duration: 9s; left: 22%; bottom: 7%; animation-delay: -7s; }
.four { --duration: 12s; right: 24%; bottom: 5%; animation-delay: -4s; }
.five { --duration: 7s; right: 3%; bottom: 31%; width: clamp(55px,8vw,112px); animation-delay: -2s; opacity: .82; }
.micro { width: 128px; image-rendering: pixelated; filter: drop-shadow(0 0 8px rgba(52,229,255,.58)); }
.micro-a { left: 7%; top: 12%; animation-delay: -1s; }
.micro-b { left: 27%; top: 14%; animation-delay: -6s; }
.micro-c { right: 21%; top: 12%; animation-delay: -3s; }
.micro-d { right: 2%; top: 51%; animation-delay: -8s; }
.callout { position: absolute; left: 54%; top: 25%; width: 175px; padding: 10px 12px; border: 1px solid rgba(99,231,255,.35); border-radius: 7px; background: rgba(4,16,39,.48); color: #d6f7ff; font: 700 10px/1.45 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
.callout::before { content: ""; position: absolute; right: 100%; top: 50%; width: 13vw; border-top: 1px solid rgba(99,231,255,.45); }
.callout b { color: #68eaff; }
@keyframes drift { from { translate: 0 0; } to { translate: 0 -16px; } }
@media (max-width: 650px) { .fleet { inset: 18% 2% 10%; } .legend { display: none; } .callout { left: 48%; top: 42%; transform: scale(.72); transform-origin: left; } .hud { max-width: 230px; } }
@media (prefers-reduced-motion: reduce) { .ship { animation: none; } }
</style>
</head>
<body>
<main class="scene">
<div class="grain"></div>
<header>
<div><p class="eyebrow">Imagen · fleet catalogue</p><h1>Asterion Patrol</h1></div>
<div class="legend">SECTOR 04 · 12:00 FORMATION</div>
</header>
<section class="fleet" aria-label="Spacecraft formation">
<img class="ship flagship" src="output/spaceships/fleet_catalog_01.png" alt="Asterion vanguard">
<img class="ship one" src="output/spaceships/fleet_catalog_05.png" alt="Asterion escort">
<img class="ship two" src="output/spaceships/fleet_catalog_09.png" alt="Asterion corvette">
<img class="ship three" src="output/spaceships/fleet_catalog_13.png" alt="Asterion scout">
<img class="ship four" src="output/spaceships/fleet_catalog_17.png" alt="Asterion support ship">
<img class="ship five" src="output/spaceships/fleet_catalog_21.png" alt="Asterion tender">
<img class="ship micro micro-a" src="output/spaceships/fleet_catalog_25.png" alt="Asterion micro scout">
<img class="ship micro micro-b" src="output/spaceships/fleet_catalog_27.png" alt="Asterion micro interceptor">
<img class="ship micro micro-c" src="output/spaceships/fleet_catalog_29.png" alt="Asterion micro patrol ship">
<img class="ship micro micro-d" src="output/spaceships/fleet_catalog_31.png" alt="Asterion micro support ship">
<div class="callout"><b>Asterion-01</b><br>pixel-perfect vanguard<br><a href="fleet-glossary.html">open glossary →</a></div>
</section>
<aside class="hud"><strong>Fleet telemetry</strong><span>New 64×64 fleet catalogue. Every visible ship is from the latest pixel-perfect generation. Hover to inspect a silhouette.</span></aside>
</main>
</body>
</html>
+64
View File
@@ -33,6 +33,17 @@ class DBStats:
avg_rating: float = 0.0
@dataclass
class StyleReference:
id: str
image_path: str
name: str
role: str
notes: Optional[str]
priority: int
created_at: str
class FeedbackDB:
"""SQLite-backed feedback database for generated sprites."""
@@ -55,6 +66,18 @@ class FeedbackDB:
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_prompt ON feedback(prompt)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_rating ON feedback(rating)")
conn.execute("""
CREATE TABLE IF NOT EXISTS style_references (
id TEXT PRIMARY KEY,
image_path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL,
notes TEXT,
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_style_priority ON style_references(priority DESC)")
conn.commit()
return cls(conn)
@@ -154,6 +177,47 @@ class FeedbackDB:
self.conn.execute("DELETE FROM feedback WHERE id = ?", (entry_id,))
self.conn.commit()
def add_style_reference(
self,
image_path: str,
name: str,
role: str,
notes: Optional[str] = None,
priority: int = 0,
) -> str:
"""Register an existing image as a reusable visual style reference."""
existing = self.conn.execute(
"SELECT id FROM style_references WHERE image_path = ?", (image_path,)
).fetchone()
if existing:
self.conn.execute(
"UPDATE style_references SET name=?, role=?, notes=?, priority=? WHERE id=?",
(name, role, notes, priority, existing[0]),
)
self.conn.commit()
return existing[0]
reference_id = str(uuid.uuid4())
self.conn.execute(
"INSERT INTO style_references (id, image_path, name, role, notes, priority, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(reference_id, image_path, name, role, notes, priority, str(int(time.time()))),
)
self.conn.commit()
return reference_id
def get_style_references(self, limit: int = 8) -> list[StyleReference]:
cursor = self.conn.execute(
"SELECT id, image_path, name, role, notes, priority, created_at "
"FROM style_references ORDER BY priority DESC, created_at DESC LIMIT ?",
(limit,),
)
return [StyleReference(*row) for row in cursor]
def delete_style_reference(self, reference_id: str) -> None:
self.conn.execute("DELETE FROM style_references WHERE id = ?", (reference_id,))
self.conn.commit()
def export_jsonl(self, path: str, min_rating: int = 4) -> int:
entries = self.top_rated(10000, min_rating)
lines = []
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Asterion Fleet Glossary</title>
<style>
*{box-sizing:border-box} body{margin:0;min-height:100vh;color:#dcecff;background:#071128 url("assets/space-nebula.png") center/cover fixed;font-family:Inter,system-ui,sans-serif} main{max-width:1120px;margin:auto;padding:54px 24px 72px} a{color:#70edff} .eyebrow{color:#78e8ff;font:700 11px ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase} h1{margin:7px 0 10px;font-size:clamp(36px,7vw,70px);letter-spacing:-.06em}.intro{max-width:630px;color:#a9c5db;line-height:1.6}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(255px,1fr));gap:14px;margin-top:35px}.card{min-height:250px;padding:19px;border:1px solid #78dffc38;border-radius:16px;background:linear-gradient(145deg,#071b3be8,#071020b8);box-shadow:0 18px 55px #02071480}.card img{display:block;width:128px;height:128px;object-fit:contain;image-rendering:pixelated;filter:drop-shadow(0 0 13px #4ee7ff99);margin:0 auto 8px}.tag{color:#72eaff;font:700 10px ui-monospace,monospace;letter-spacing:.12em}.card h2{font-size:20px;margin:5px 0}.card p{margin:0;color:#a9c5db;font-size:13px;line-height:1.55}.foot{margin-top:34px;color:#8faec5;font-size:12px}</style></head>
<body><main><a class="eyebrow" href="demo.html">← return to scene</a><h1>Asterion Fleet</h1><p class="intro">A working glossary for the newly generated 64×64 fleet. All entries share a pixel-perfect grid, blue-gray armour, cyan propulsion and a strict north-facing top-down silhouette.</p><section class="grid">
<article class="card"><img src="output/spaceships/fleet_catalog_01.png" alt="Needle scout"><span class="tag">AST-01 · Recon</span><h2>Needle Scout</h2><p>Fast survey craft. Its narrow nose and compact engine pair make it legible at the smallest tactical scale.</p></article>
<article class="card"><img src="output/spaceships/fleet_catalog_05.png" alt="Mirror interceptor"><span class="tag">AST-05 · Intercept</span><h2>Mirror Interceptor</h2><p>Symmetric pursuit hull with swept wings, designed to anchor the visual language of the patrol wing.</p></article>
<article class="card"><img src="output/spaceships/fleet_catalog_09.png" alt="Missile corvette"><span class="tag">AST-09 · Strike</span><h2>Missile Corvette</h2><p>Compact midline striker. The broad central volume makes it a readable combat silhouette without extra effects.</p></article>
<article class="card"><img src="output/spaceships/fleet_catalog_13.png" alt="Support frigate"><span class="tag">AST-13 · Support</span><h2>Support Frigate</h2><p>Wide, stable frame intended for repair and relay duties; its wings visually distinguish it from the attack craft.</p></article>
<article class="card"><img src="output/spaceships/fleet_catalog_17.png" alt="Patrol ship"><span class="tag">AST-17 · Patrol</span><h2>Diamond Patrol Ship</h2><p>Heavy symmetric hull for perimeter duty. The diamond centre reads clearly in formation and in isolation.</p></article>
<article class="card"><img src="output/spaceships/fleet_catalog_21.png" alt="Cargo tender"><span class="tag">AST-21 · Logistics</span><h2>Cargo Tender</h2><p>Utility vessel for fuel and supplies. Its softer, wider profile adds a practical non-combat role to the fleet.</p></article>
</section><p class="foot">Source batch: PixelLab · 64×64 · transparent PNG · prompt and generation provenance embedded in each file.</p></main></body></html>
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Asterion Patrol — Demo Game</title>
<style>
*{box-sizing:border-box} body{margin:0;min-height:100vh;display:grid;place-items:center;background:#030817;color:#e1f5ff;font-family:Inter,system-ui,sans-serif;overflow:hidden}
.shell{position:relative;width:min(100vw,1100px);aspect-ratio:16/9;box-shadow:0 30px 90px #000;border:1px solid #71e9ff42;background:#071128}
canvas{display:block;width:100%;height:100%;image-rendering:pixelated;cursor:crosshair}.hud{position:absolute;inset:0;pointer-events:none;padding:20px;display:flex;justify-content:space-between;text-shadow:0 2px 8px #000;font:700 13px ui-monospace,monospace;letter-spacing:.08em}.hud b{color:#75edff}.tip{position:absolute;left:20px;bottom:17px;margin:0;color:#b7cce1;font-size:11px;line-height:1.5;text-shadow:0 2px 5px #000}.tip kbd{padding:2px 5px;border:1px solid #80dbef66;border-radius:4px;background:#061a32;font-family:inherit}.overlay{position:absolute;inset:0;display:grid;place-items:center;text-align:center;background:#030916aa;backdrop-filter:blur(3px)}.overlay.hidden{display:none}.panel{padding:26px 34px;border:1px solid #76eaff55;border-radius:12px;background:#07172bd9;box-shadow:0 0 50px #31c9ff30}.panel h1{margin:0 0 8px;font-size:28px;letter-spacing:-.04em}.panel p{margin:0;color:#b4cce0;font-size:13px}.panel button{margin-top:18px;padding:10px 16px;border:0;border-radius:6px;background:#69eaff;color:#031221;font-weight:800;cursor:pointer}@media(max-width:640px){.shell{width:100vw}.hud{padding:12px;font-size:10px}.tip{left:12px;bottom:10px;font-size:9px}}
</style>
</head>
<body><section class="shell"><canvas id="game" width="960" height="540" aria-label="Asterion Patrol game"></canvas>
<div class="hud"><div>ASTERION PATROL<br><b id="score">SCORE 00000</b></div><div id="status">HULL <b>■■■</b></div></div>
<p class="tip"><kbd>WASD</kbd>/<kbd>←↑→↓</kbd> move · <kbd>SPACE</kbd> or click fire · <kbd>P</kbd> pause</p>
<div id="overlay" class="overlay"><div class="panel"><h1>ASTERION PATROL</h1><p>Defend the nebula corridor.</p><button id="start">Launch mission</button></div></div>
</section>
<script>
const canvas=document.querySelector('#game'), ctx=canvas.getContext('2d'); ctx.imageSmoothingEnabled=false;
const scoreEl=document.querySelector('#score'), statusEl=document.querySelector('#status'), overlay=document.querySelector('#overlay'), start=document.querySelector('#start');
const load=src=>{const i=new Image();i.src=src;return i};
const bg=load('assets/space-nebula.png'), playerImg=load('output/spaceships/fleet_catalog_01.png'), enemyImgs=[load('output/spaceships/fleet_catalog_05.png'),load('output/spaceships/fleet_catalog_09.png'),load('output/spaceships/fleet_catalog_17.png')], impact=load('assets/plasma-impact.png');
let keys={}, bullets=[], enemies=[], bursts=[], player, score, last=0, spawn=0, running=false, paused=false;
function reset(){player={x:480,y:445,r:24,hp:3,cool:0};bullets=[];enemies=[];bursts=[];score=0;spawn=0;scoreEl.textContent='SCORE 00000';statusEl.innerHTML='HULL <b>■■■</b>'}
function fire(){if(!running||paused||player.cool>0)return;player.cool=.18;bullets.push({x:player.x,y:player.y-34,vy:-520});}
function spawnEnemy(){const img=enemyImgs[Math.floor(Math.random()*enemyImgs.length)];enemies.push({x:55+Math.random()*850,y:-45,v:45+Math.random()*45,r:23,img,hp:1,wiggle:Math.random()*6});}
function hit(x,y){bursts.push({x,y,t:0});}
function update(dt){if(!running||paused)return;const speed=260;let dx=(keys.ArrowRight||keys.d?1:0)-(keys.ArrowLeft||keys.a?1:0),dy=(keys.ArrowDown||keys.s?1:0)-(keys.ArrowUp||keys.w?1:0);if(dx||dy){const l=Math.hypot(dx,dy);player.x+=dx/l*speed*dt;player.y+=dy/l*speed*dt}player.x=Math.max(28,Math.min(932,player.x));player.y=Math.max(70,Math.min(500,player.y));player.cool=Math.max(0,player.cool-dt);spawn-=dt;if(spawn<=0){spawn=.65+Math.random()*.55;spawnEnemy()}bullets.forEach(b=>b.y+=b.vy*dt);bullets=bullets.filter(b=>b.y>-15);enemies.forEach(e=>{e.y+=e.v*dt;e.x+=Math.sin(performance.now()/700+e.wiggle)*20*dt});for(const b of bullets){for(const e of enemies){if(!e.dead&&Math.hypot(b.x-e.x,b.y-e.y)<e.r+5){e.dead=true;b.dead=true;score+=125;hit(e.x,e.y)}}}enemies=enemies.filter(e=>{if(e.dead)return false;if(e.y>575){player.hp--;hit(player.x,player.y);return false}return true});bullets=bullets.filter(b=>!b.dead);bursts.forEach(b=>b.t+=dt);bursts=bursts.filter(b=>b.t<.64);scoreEl.textContent='SCORE '+String(score).padStart(5,'0');statusEl.innerHTML='HULL <b>'+ ''.repeat(Math.max(0,player.hp))+''.repeat(3-Math.max(0,player.hp))+'</b>';if(player.hp<=0){running=false;overlay.classList.remove('hidden');overlay.querySelector('h1').textContent='MISSION LOST';overlay.querySelector('p').textContent='Score: '+score;start.textContent='Retry mission'}}
function ship(img,x,y,size,rotation=0){ctx.save();ctx.translate(x,y);ctx.rotate(rotation);ctx.drawImage(img,-size/2,-size/2,size,size);ctx.restore()}
function draw(){ctx.clearRect(0,0,960,540);if(bg.complete){ctx.drawImage(bg,0,0,960,540)}else{ctx.fillStyle='#071128';ctx.fillRect(0,0,960,540)}ctx.fillStyle='rgba(4,12,30,.38)';ctx.fillRect(0,0,960,540);for(const b of bullets){ctx.fillStyle='#7effff';ctx.fillRect(b.x-2,b.y-12,4,16);ctx.fillStyle='#fff';ctx.fillRect(b.x-1,b.y-10,2,10)}enemies.forEach(e=>ship(e.img,e.x,e.y,66,Math.PI));if(player)ship(playerImg,player.x,player.y,76);bursts.forEach(b=>{if(!impact.complete)return;const frame=Math.min(3,Math.floor(b.t/.16)), fw=impact.naturalWidth/4, fh=impact.naturalHeight;ctx.save();ctx.globalCompositeOperation='screen';ctx.drawImage(impact,frame*fw,0,fw,fh,b.x-47,b.y-47,94,94);ctx.restore()})}
function frame(t){const dt=Math.min(.033,(t-last)/1000||0);last=t;update(dt);draw();requestAnimationFrame(frame)}requestAnimationFrame(frame);
window.addEventListener('keydown',e=>{keys[e.key]=true;if(e.code==='Space'){e.preventDefault();fire()}if(e.key.toLowerCase()==='p'&&running){paused=!paused}});window.addEventListener('keyup',e=>keys[e.key]=false);canvas.addEventListener('pointerdown',fire);start.addEventListener('click',()=>{reset();running=true;paused=false;overlay.classList.add('hidden')});
</script></body></html>
+110
View File
@@ -0,0 +1,110 @@
"""Minimal PixelLab v2 client for style-consistent pixel-art generation."""
import base64
import json
import os
import time
from io import BytesIO
from typing import Any, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from PIL import Image
API_URL = "https://api.pixellab.ai/v2"
class PixelLabError(RuntimeError):
pass
class PixelLabClient:
def __init__(self, api_key: Optional[str] = None, api_url: str = API_URL):
self.api_key = api_key or os.environ.get("PIXELLAB_API_KEY")
self.api_url = api_url.rstrip("/")
def _request(self, method: str, path: str, payload: Optional[dict] = None) -> dict:
if not self.api_key:
raise PixelLabError("PIXELLAB_API_KEY is not configured.")
request = Request(
f"{self.api_url}{path}",
data=json.dumps(payload).encode() if payload else None,
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
method=method,
)
try:
with urlopen(request, timeout=125) as response:
return json.loads(response.read().decode())
except HTTPError as exc:
raise PixelLabError(f"PixelLab API returned HTTP {exc.code}: {exc.read().decode(errors='replace')}") from exc
except (URLError, TimeoutError) as exc:
raise PixelLabError(f"Could not reach PixelLab API: {exc}") from exc
@staticmethod
def _reference(value: str) -> dict:
if value.startswith("data:"):
raw = base64.b64decode(value.split(",", 1)[1])
elif value.startswith(("https://", "http://")):
with urlopen(value, timeout=60) as response:
raw = response.read()
else:
with open(os.path.expanduser(value), "rb") as image_file:
raw = image_file.read()
with Image.open(BytesIO(raw)) as image:
image = image.convert("RGBA")
image.thumbnail((512, 512), Image.Resampling.LANCZOS)
width, height = image.size
output = BytesIO()
image.save(output, format="PNG")
encoded = "data:image/png;base64," + base64.b64encode(output.getvalue()).decode()
return {"image": {"base64": encoded}, "width": width, "height": height}
def create_image(self, description: str, width: int, height: int, *, seed: Optional[int] = None,
reference_images: Optional[list[str]] = None, no_background: bool = True) -> dict:
refs = [self._reference(ref) for ref in (reference_images or [])]
if refs:
payload: dict[str, Any] = {
"description": description, "image_size": {"width": width, "height": height},
"style_images": refs[:4], "no_background": no_background,
}
if seed is not None:
payload["seed"] = seed
return self._request("POST", "/generate-with-style-v2", payload)
payload = {"description": description, "image_size": {"width": width, "height": height},
"no_background": no_background}
if seed is not None:
payload["seed"] = seed
return self._request("POST", "/generate-image-v2", payload)
def get_status(self, job_id: str) -> dict:
return self._request("GET", f"/background-jobs/{job_id}")
def wait_for_completion(self, job_id: str, timeout_seconds: int = 115) -> dict:
deadline = time.monotonic() + timeout_seconds
while True:
status = self.get_status(job_id)
state = status.get("status", "").lower()
if state == "completed":
return status
if state in {"failed", "error", "cancelled"}:
raise PixelLabError(str(status.get("last_response") or state))
if time.monotonic() >= deadline:
return status
time.sleep(2)
@staticmethod
def image_sources(response: dict) -> list[str]:
sources: list[str] = []
def visit(value: Any) -> None:
if isinstance(value, dict):
for key, item in value.items():
if key in {"url", "base64", "b64_json"} and isinstance(item, str):
sources.append(item)
else:
visit(item)
elif isinstance(value, list):
for item in value:
visit(item)
visit(response.get("last_response", response))
return list(dict.fromkeys(sources))
+155
View File
@@ -0,0 +1,155 @@
"""Client for Polza.ai's media API.
The client deliberately uses the standard library so enabling cloud generation
does not add a runtime dependency to the local Diffusers installation.
"""
import base64
import json
import mimetypes
import os
import time
from pathlib import Path
from typing import Any, Optional
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
API_URL = "https://polza.ai/api/v1"
MAX_REFERENCE_BYTES = 50 * 1024 * 1024
class PolzaError(RuntimeError):
"""An API or transport error returned by Polza.ai."""
class PolzaClient:
def __init__(self, api_key: Optional[str] = None, api_url: str = API_URL):
self.api_key = api_key or os.environ.get("POLZA_API_KEY")
self.api_url = api_url.rstrip("/")
def _headers(self) -> dict[str, str]:
if not self.api_key:
raise PolzaError(
"POLZA_API_KEY is not configured. Set it in the MCP server environment."
)
return {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
def _request(self, method: str, path: str, payload: Optional[dict] = None) -> dict:
body = json.dumps(payload).encode("utf-8") if payload is not None else None
request = Request(
f"{self.api_url}{path}", data=body, headers=self._headers(), method=method
)
try:
with urlopen(request, timeout=125) as response:
result = json.loads(response.read().decode("utf-8"))
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
try:
detail = json.loads(detail).get("error", {}).get("message", detail)
except json.JSONDecodeError:
pass
raise PolzaError(f"Polza API returned HTTP {exc.code}: {detail}") from exc
except (URLError, TimeoutError) as exc:
raise PolzaError(f"Could not reach Polza API: {exc}") from exc
if result.get("error"):
error = result["error"]
raise PolzaError(error.get("message", str(error)))
return result
@staticmethod
def reference_payload(reference: str) -> dict[str, str]:
"""Convert an HTTPS URL, data URI, or local image path for Media API."""
if reference.startswith(("https://", "http://")):
return {"type": "url", "data": reference}
if reference.startswith("data:"):
return {"type": "base64", "data": reference}
path = Path(reference).expanduser()
if not path.is_file():
raise PolzaError(f"Reference image does not exist: {reference}")
if path.stat().st_size > MAX_REFERENCE_BYTES:
raise PolzaError(f"Reference image exceeds the 50 MB API limit: {reference}")
mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return {"type": "base64", "data": f"data:{mime_type};base64,{encoded}"}
def create_image(
self,
*,
model: str,
prompt: str,
reference_images: Optional[list[str]] = None,
count: int = 1,
aspect_ratio: Optional[str] = None,
seed: Optional[int] = None,
quality: Optional[str] = None,
output_format: str = "png",
background: Optional[str] = None,
provider: Optional[dict] = None,
wait: bool = True,
) -> dict:
if not 1 <= count <= 10:
raise PolzaError("count must be between 1 and 10")
input_data: dict[str, Any] = {
"prompt": prompt,
"max_images": count,
"output_format": output_format,
}
if reference_images:
input_data["images"] = [self.reference_payload(ref) for ref in reference_images]
for name, value in {
"aspect_ratio": aspect_ratio,
"seed": seed,
"quality": quality,
"background": background,
}.items():
if value is not None:
input_data[name] = value
payload: dict[str, Any] = {"model": model, "input": input_data, "async": not wait}
if provider:
payload["provider"] = provider
return self._request("POST", "/media", payload)
def get_status(self, generation_id: str) -> dict:
return self._request("GET", f"/media/{generation_id}")
def wait_for_completion(
self, generation_id: str, timeout_seconds: int = 115, poll_seconds: float = 2
) -> dict:
deadline = time.monotonic() + timeout_seconds
while True:
status = self.get_status(generation_id)
state = status.get("status", "").lower()
if state in {"completed", "succeeded", "success"}:
return status
if state in {"failed", "error", "cancelled", "canceled"}:
error = status.get("error") or {}
raise PolzaError(error.get("message", f"Generation {generation_id} {state}"))
if time.monotonic() >= deadline:
return status
time.sleep(poll_seconds)
@staticmethod
def image_sources(response: dict) -> list[str]:
"""Extract CDN URLs or base64 images from documented response variants."""
found: list[str] = []
def visit(value: Any) -> None:
if isinstance(value, dict):
for key, item in value.items():
if key in {"url", "b64_json", "base64"} and isinstance(item, str):
found.append(item)
elif key in {"data", "output", "images", "result", "results"}:
visit(item)
elif isinstance(value, list):
for item in value:
visit(item)
visit(response)
return list(dict.fromkeys(found))
+460 -8
View File
@@ -18,16 +18,41 @@ Every generated sprite is automatically saved to the feedback DB (unrated).
import os
import sys
import time
import base64
import json
from io import BytesIO
from typing import Optional
from urllib.request import urlopen
import numpy as np
from PIL import Image
from PIL import Image, PngImagePlugin
from mcp.server.fastmcp import FastMCP
from feedback import FeedbackDB
from polza import PolzaClient, PolzaError
from pixellab import PixelLabClient
def _load_dotenv(path: str) -> None:
"""Load a minimal KEY=value .env file without overriding real environment."""
if not os.path.isfile(path):
return
with open(path, encoding="utf-8") as env_file:
for raw_line in env_file:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key, value = key.strip(), value.strip()
if value[:1] == value[-1:] and value[:1] in {"'", '"'}:
value = value[1:-1]
if key:
os.environ.setdefault(key, value)
# Paths — models live in a shared location
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_load_dotenv(os.path.join(BASE_DIR, ".env"))
MODEL_DIR = os.environ.get(
"IMAGEGEN_MODEL_DIR",
os.path.join(os.path.expanduser("~"), "models", "sdxl-base"),
@@ -42,6 +67,7 @@ LCM_LORA_DIR = os.environ.get(
)
OUTPUT_DIR = os.environ.get("IMAGEGEN_OUTPUT_DIR", os.path.join(BASE_DIR, "output"))
DB_PATH = os.environ.get("IMAGEGEN_DB_PATH", os.path.join(BASE_DIR, "feedback.db"))
POLZA_MODEL = os.environ.get("POLZA_IMAGE_MODEL", "openai/gpt-image-1.5")
# LoRA scales — pixel-art-xl needs 1.2, LCM needs 1.0
PIXEL_LORA_SCALE = 1.2
@@ -222,8 +248,214 @@ def _ensure_dir(path: str):
os.makedirs(dir_path, exist_ok=True)
def _save_sprite(image: Image.Image, path: str, prompt: str, metadata: dict) -> None:
"""Save a PNG with portable, machine-readable generation provenance."""
_ensure_dir(path)
png_info = PngImagePlugin.PngInfo()
png_info.add_text("prompt", prompt)
png_info.add_text("imagen", json.dumps(metadata, ensure_ascii=False))
image.save(path, format="PNG", pnginfo=png_info)
def _output_paths(output_path: str, count: int) -> list[str]:
"""Keep a requested filename for one result; suffix variants for many."""
if not os.path.isabs(output_path):
output_path = os.path.join(OUTPUT_DIR, output_path)
if count == 1:
return [output_path]
stem, extension = os.path.splitext(output_path)
extension = extension or ".png"
return [f"{stem}_{index:02d}{extension}" for index in range(1, count + 1)]
def _image_from_source(source: str) -> Image.Image:
if source.startswith("data:"):
source = source.split(",", 1)[-1]
if source.startswith(("http://", "https://")):
with urlopen(source, timeout=60) as response:
data = response.read()
else:
data = base64.b64decode(source)
return Image.open(BytesIO(data)).convert("RGBA")
def _polza_generate(
prompt: str,
output_path: str,
*,
count: int = 1,
model: Optional[str] = None,
reference_images: Optional[list[str]] = None,
aspect_ratio: Optional[str] = None,
seed: Optional[int] = None,
quality: Optional[str] = None,
remove_bg: bool = True,
pixel_size: int = 4,
wait: bool = True,
) -> list[dict]:
"""Generate and persist image variants through Polza Media API."""
client = PolzaClient()
full_prompt = _build_prompt(prompt)
started = time.time()
selected_model = model or POLZA_MODEL
response = client.create_image(
model=selected_model,
prompt=full_prompt,
reference_images=reference_images,
count=count,
aspect_ratio=aspect_ratio,
seed=seed,
quality=quality,
# The transparent-background option is specific to GPT Image. Other
# models still get transparent PNGs through local post-processing.
background="transparent" if remove_bg and "gpt-image" in selected_model else None,
wait=wait,
)
task_id = response.get("id")
state = response.get("status", "").lower()
if wait and task_id and state in {"pending", "queued", "processing", "running"}:
response = client.wait_for_completion(task_id)
sources = client.image_sources(response)
if not sources:
return [{
"generation_id": task_id,
"status": response.get("status", "pending"),
"provider": "polza",
"model": response.get("model", model or POLZA_MODEL),
"message": "Generation is still running; call get_generation_status later.",
}]
paths = _output_paths(output_path, len(sources))
results = []
for index, (source, path) in enumerate(zip(sources, paths), start=1):
_ensure_dir(path)
image = _image_from_source(source)
if pixel_size > 0:
image = _pixelate(image, pixel_size)
if remove_bg:
image = _remove_background(image)
_save_sprite(image, path, prompt, {"provider": "polza", "full_prompt": full_prompt,
"generation_id": task_id, "model": response.get("model", model or POLZA_MODEL)})
params = {
"provider": "polza",
"generation_id": task_id,
"model": response.get("model", model or POLZA_MODEL),
"seed": seed,
"reference_images": reference_images or [],
"aspect_ratio": aspect_ratio,
"quality": quality,
"remove_bg": remove_bg,
"pixel_size": pixel_size,
"full_prompt": full_prompt,
"usage": response.get("usage"),
"warnings": response.get("warnings", []),
}
entry_id = _get_db().add(prompt=prompt, params=params, image_path=path)
results.append({
"output_path": path,
"source_url": source if source.startswith(("http://", "https://")) else None,
"variant": index,
"db_id": entry_id,
"generation_id": task_id,
"generation_time": f"{time.time() - started:.1f}s",
"prompt": full_prompt,
"provider": "polza",
"model": response.get("model", model or POLZA_MODEL),
"usage": response.get("usage"),
"warnings": response.get("warnings", []),
"rated": False,
})
return results
def _pixellab_generate(
prompt: str, output_path: str, *, count: int = 1, reference_images: Optional[list[str]] = None,
seed: Optional[int] = None, width: int = 128, height: int = 128, remove_bg: bool = True,
pixel_size: int = 0, wait: bool = True,
) -> list[dict]:
"""Generate native pixel-art sprites through PixelLab v2."""
client = PixelLabClient()
full_prompt = _build_prompt(prompt)
responses = [client.create_image(full_prompt, width, height, seed=seed, reference_images=reference_images,
no_background=remove_bg) for _ in range(count)]
completed = []
for response in responses:
job_id = response.get("background_job_id")
if wait and job_id:
response = client.wait_for_completion(job_id)
completed.append((job_id, response))
source_records = [(job_id, response, source) for job_id, response in completed
for source in client.image_sources(response)]
if not source_records:
return [{"generation_id": job_id, "status": response.get("status", "processing"),
"provider": "pixellab", "usage": response.get("usage")}
for job_id, response in completed]
results = []
for index, ((job_id, response, source), path) in enumerate(
zip(source_records, _output_paths(output_path, len(source_records))), start=1
):
_ensure_dir(path)
image = _image_from_source(source)
if pixel_size > 0:
image = _pixelate(image, pixel_size)
if remove_bg:
image = _remove_background(image)
_save_sprite(image, path, prompt, {"provider": "pixellab", "full_prompt": full_prompt,
"generation_id": job_id, "width": width, "height": height})
db_id = _get_db().add(prompt=prompt, image_path=path, params={
"provider": "pixellab", "generation_id": job_id, "reference_images": reference_images or [],
"seed": seed, "width": width, "height": height, "remove_bg": remove_bg,
"pixel_size": pixel_size, "full_prompt": full_prompt, "usage": response.get("usage"),
})
results.append({"output_path": path, "db_id": db_id, "generation_id": job_id, "variant": index,
"provider": "pixellab", "usage": response.get("usage"), "rated": False})
return results
# Instructions are part of the MCP initialization response, so every connected
# agent receives the asset-generation rules before it chooses a tool.
MCP_INSTRUCTIONS = """
Generate game assets deliberately and keep the dataset reusable.
Style workflow:
1. Call get_project_style_guide first. Its registered references live in the
server database; pass 14 selected paths as reference_images to
provider='pixellab' for a coherent style family. get_reference_sprites is
useful for additional rated examples.
2. Generate one semantic asset role per prompt (for example only a missile
corvette). Never put a list of roles in one prompt when individual metadata
must identify each image: all outputs of a multi-image batch inherit the
same prompt and cannot reliably be labelled afterwards.
3. State camera, orientation, silhouette, palette, background, and exclusions
explicitly. For top-down fleets, say: 'orthographic top-down; nose at 12
o'clock; engines at 6 o'clock'. Add exact vertical mirror symmetry only
when it is artistically required.
Pixel-art rules:
- For pixel-perfect assets use PixelLab at a native square size (normally
64x64 or 128x128), transparent background, pixel_size=0, and request no
anti-aliasing, blur, sub-pixel shading, or semi-transparent edges.
- In HTML/game clients display these files at an integer multiple with
image-rendering: pixelated; never use arbitrary CSS scaling.
- Use provider='pixellab' for native game sprites and style references.
Use provider='polza' for broader concept art or backgrounds. Local is an
optional Diffusers/GPU provider.
Operations and provenance:
- Use a semantic output_path and preserve every returned db_id. Finished PNGs
embed 'prompt' and 'imagen' JSON metadata (provider, full prompt and
generation settings); the feedback database stores the same provenance.
- generate_images is for variants of one precise asset. batch_generate is for
several independent, precisely described assets. For async work use wait=false
and get_generation_status with the matching provider.
- Never expose, request, or store API keys in prompts, output paths, feedback,
or image metadata.
""".strip()
# Create MCP server
mcp = FastMCP("pixel-art")
mcp = FastMCP("pixel-art", instructions=MCP_INSTRUCTIONS)
@mcp.tool()
@@ -236,8 +468,18 @@ def generate_sprite(
steps: int = 8,
remove_bg: bool = True,
pixel_size: int = 4,
provider: str = "local",
model: Optional[str] = None,
reference_images: Optional[list[str]] = None,
aspect_ratio: Optional[str] = None,
quality: Optional[str] = None,
) -> dict:
"""Generate a pixel-art sprite and save it as PNG with transparent background.
"""Generate one precisely described pixel-art sprite and save it as PNG.
For a reusable game dataset, describe only one semantic role per call and
state view/orientation/palette explicitly. Use provider='pixellab' plus
14 reference_images for style-consistent native pixel art. Each PNG
embeds the prompt and generation provenance in its metadata.
Args:
prompt: Description of the sprite (e.g. "a crystal warrior with geometric armor")
@@ -247,11 +489,36 @@ def generate_sprite(
height: Image height in pixels (default 512)
steps: Inference steps (default 4, FLUX.2-klein is distilled)
remove_bg: Remove background and make transparent (default True)
pixel_size: Size of each pixel block for pixel-art effect (default 4, 0=off)
pixel_size: Size of each pixel block for pixel-art effect (default 4, 0=off)
provider: "local" (SDXL) or "polza" (cloud image models)
model: Polza model ID; defaults to POLZA_IMAGE_MODEL
reference_images: Style references: HTTPS URLs, data URIs, or local image paths
aspect_ratio: Cloud aspect ratio such as "1:1" or "16:9"
quality: Cloud model quality setting
Returns:
Dict with output_path, seed_used, generation_time, prompt, size.
"""
if provider == "polza":
return _polza_generate(
prompt,
output_path,
model=model,
reference_images=reference_images,
aspect_ratio=aspect_ratio,
seed=seed,
quality=quality,
remove_bg=remove_bg,
pixel_size=pixel_size,
)[0]
if provider == "pixellab":
return _pixellab_generate(prompt, output_path, reference_images=reference_images, seed=seed,
width=width, height=height, remove_bg=remove_bg, pixel_size=pixel_size)[0]
if provider != "local":
raise ValueError("provider must be 'local', 'polza', or 'pixellab'")
if reference_images:
raise ValueError("reference_images require provider='polza'")
pipe = _load_model()
full_prompt = _build_prompt(prompt)
@@ -269,7 +536,8 @@ def generate_sprite(
if remove_bg:
image = _remove_background(image)
image.save(output_path)
_save_sprite(image, output_path, prompt, {"provider": "local", "full_prompt": full_prompt,
"seed": seed, "width": width, "height": height, "steps": steps})
elapsed = time.time() - t0
db = _get_db()
@@ -312,7 +580,12 @@ def generate_sprite(
def batch_generate(
specs: list[dict],
) -> list[dict]:
"""Generate multiple pixel-art sprites in one call.
"""Generate multiple independent, precisely described sprites in one call.
Each spec must represent one asset role. Do not ask one spec for mixed
categories if you need per-image semantic metadata: every result inherits
that spec's single prompt. For a game style family, give every PixelLab spec
the same selected reference_images and native 64x64 or 128x128 dimensions.
Args:
specs: List of dicts, each with:
@@ -328,7 +601,7 @@ def batch_generate(
Returns:
List of dicts with output_path, seed_used, generation_time, prompt, size, transparent.
"""
pipe = _load_model()
pipe = None
results = []
for spec in specs:
@@ -340,6 +613,51 @@ def batch_generate(
steps = spec.get("steps", 8)
remove_bg = spec.get("remove_bg", True)
pixel_size = spec.get("pixel_size", 4)
provider = spec.get("provider", "local")
model = spec.get("model")
reference_images = spec.get("reference_images")
aspect_ratio = spec.get("aspect_ratio")
quality = spec.get("quality")
if provider == "polza":
results.extend(
_polza_generate(
prompt,
output_path,
count=spec.get("count", 1),
model=model,
reference_images=reference_images,
aspect_ratio=aspect_ratio,
seed=seed,
quality=quality,
remove_bg=remove_bg,
pixel_size=pixel_size,
wait=spec.get("wait", True),
)
)
continue
if provider == "pixellab":
results.extend(
_pixellab_generate(
prompt,
output_path,
count=spec.get("count", 1),
reference_images=reference_images,
seed=seed,
width=width,
height=height,
remove_bg=remove_bg,
pixel_size=pixel_size,
wait=spec.get("wait", True),
)
)
continue
if provider != "local":
raise ValueError("provider must be 'local', 'polza', or 'pixellab'")
if reference_images:
raise ValueError("reference_images require provider='polza'")
if pipe is None:
pipe = _load_model()
full_prompt = _build_prompt(prompt)
@@ -357,7 +675,8 @@ def batch_generate(
if remove_bg:
image = _remove_background(image)
image.save(output_path)
_save_sprite(image, output_path, prompt, {"provider": "local", "full_prompt": full_prompt,
"seed": seed, "width": width, "height": height, "steps": steps})
elapsed = time.time() - t0
db = _get_db()
@@ -399,6 +718,80 @@ def batch_generate(
return results
@mcp.tool()
def generate_images(
prompt: str,
output_path: str,
count: int = 1,
model: Optional[str] = None,
reference_images: Optional[list[str]] = None,
aspect_ratio: Optional[str] = None,
seed: Optional[int] = None,
quality: Optional[str] = None,
remove_bg: bool = True,
pixel_size: int = 4,
wait: bool = True,
provider: str = "polza",
) -> list[dict]:
"""Generate cloud-image variations of one precise asset request.
Use ``reference_images`` for a game's style guide, existing characters, or
tiles. Each value may be an HTTPS URL, data URI, or a path readable by this
MCP server. Results use output_path_01.png, output_path_02.png, etc.
When ``wait`` is false, the returned generation_id can be passed to
get_generation_status later. Use separate calls (or batch_generate specs)
for different roles such as scout, corvette, and freighter, so embedded
prompt metadata remains meaningful for every image. For pixel-perfect
assets choose provider='pixellab'; it uses a native 128x128 output here.
"""
if provider == "pixellab":
# PixelLab produces native pixel art; its practical size range is 16512.
# aspect_ratio is not applicable because its API accepts explicit dimensions.
return _pixellab_generate(
prompt, output_path, count=count, reference_images=reference_images, seed=seed,
remove_bg=remove_bg, pixel_size=pixel_size, wait=wait,
)
if provider != "polza":
raise ValueError("provider must be 'polza' or 'pixellab'")
return _polza_generate(
prompt,
output_path,
count=count,
model=model,
reference_images=reference_images,
aspect_ratio=aspect_ratio,
seed=seed,
quality=quality,
remove_bg=remove_bg,
pixel_size=pixel_size,
wait=wait,
)
@mcp.tool()
def get_generation_status(generation_id: str, provider: str = "polza") -> dict:
"""Get a Polza or PixelLab cloud-generation status and finished sources."""
if provider == "pixellab":
status = PixelLabClient().get_status(generation_id)
return {
"generation_id": status.get("id", generation_id), "status": status.get("status"),
"provider": "pixellab", "image_sources": PixelLabClient.image_sources(status),
"usage": status.get("usage"), "error": status.get("error"),
}
if provider != "polza":
raise ValueError("provider must be 'polza' or 'pixellab'")
status = PolzaClient().get_status(generation_id)
return {
"generation_id": status.get("id", generation_id),
"status": status.get("status"),
"model": status.get("model"),
"image_sources": PolzaClient.image_sources(status),
"usage": status.get("usage"),
"warnings": status.get("warnings", []),
"error": status.get("error"),
}
@mcp.tool()
def rate_sprite(
db_id: str,
@@ -468,6 +861,65 @@ def get_reference_sprites(
]
@mcp.tool()
def get_project_style_guide(limit: int = 4) -> dict:
"""Return the server's current reusable style guide and reference images.
Call this before generating a coordinated asset family. The references are
registered in the MCP server's SQLite DB, rather than hard-coded in a
client. Pass the returned recommended_reference_images to PixelLab.
"""
references = _get_db().get_style_references(limit)
return {
"rules": {
"pixel_art": "Use native 64x64 or 128x128 PNG and integer client scaling.",
"top_down": "State orthographic camera, nose at 12 o'clock, engines at 6 o'clock.",
"metadata": "Use one semantic asset role per generation prompt.",
},
"recommended_reference_images": [reference.image_path for reference in references],
"references": [
{
"reference_id": reference.id,
"image_path": reference.image_path,
"name": reference.name,
"role": reference.role,
"notes": reference.notes,
"priority": reference.priority,
}
for reference in references
],
}
@mcp.tool()
def register_style_reference(
image_path: str,
name: str,
role: str,
notes: Optional[str] = None,
priority: int = 0,
) -> dict:
"""Register an existing generated image as a reusable project style reference.
Use a stable, well-reviewed PNG. Higher priority references are returned
first by get_project_style_guide. Register 14 complementary examples, not
many near-duplicates.
"""
if not os.path.isfile(image_path):
raise ValueError(f"Style reference image does not exist: {image_path}")
reference_id = _get_db().add_style_reference(
image_path, name, role, notes, priority
)
return {"reference_id": reference_id, "status": "saved", "image_path": image_path}
@mcp.tool()
def remove_style_reference(reference_id: str) -> dict:
"""Remove a style-reference registration; the image file is not deleted."""
_get_db().delete_style_reference(reference_id)
return {"reference_id": reference_id, "status": "removed"}
@mcp.tool()
def list_sprites(
filter: str = "all",
+25 -1
View File
@@ -10,7 +10,7 @@ import tempfile
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from feedback import FeedbackDB, FeedbackEntry, DBStats, _tokenize
from feedback import FeedbackDB, FeedbackEntry, DBStats, StyleReference, _tokenize
@pytest.fixture
@@ -283,6 +283,30 @@ class TestDelete:
assert entries[0].prompt == "archer"
class TestStyleReferences:
def test_add_and_list_style_reference(self, db):
reference_id = db.add_style_reference(
"/tmp/style.png", "Asterion vanguard", "fleet style", "cyan engines", 10
)
references = db.get_style_references()
assert references[0].id == reference_id
assert isinstance(references[0], StyleReference)
assert references[0].name == "Asterion vanguard"
def test_style_reference_upserts_by_image_path(self, db):
first = db.add_style_reference("/tmp/style.png", "old", "style")
second = db.add_style_reference("/tmp/style.png", "new", "style", priority=5)
assert first == second
references = db.get_style_references()
assert len(references) == 1
assert references[0].name == "new"
def test_delete_style_reference(self, db):
reference_id = db.add_style_reference("/tmp/style.png", "ship", "style")
db.delete_style_reference(reference_id)
assert db.get_style_references() == []
class TestExportJsonl:
def test_export_jsonl(self, db, tmp_path):
_add_sample(db, "knight", rating=5, feedback="great")
+24
View File
@@ -0,0 +1,24 @@
from pixellab import PixelLabClient
def test_image_sources_reads_completed_job_images():
response = {
"last_response": {
"images": [{"base64": "data:image/png;base64,AAAA"}],
"url": "https://example.com/preview.png",
}
}
assert PixelLabClient.image_sources(response) == [
"data:image/png;base64,AAAA",
"https://example.com/preview.png",
]
def test_create_image_uses_style_endpoint(monkeypatch):
client = PixelLabClient(api_key="test")
monkeypatch.setattr(client, "_reference", lambda _: {"image": {"base64": "x"}, "width": 64, "height": 64})
captured = {}
monkeypatch.setattr(client, "_request", lambda method, path, payload: captured.update(method=method, path=path, payload=payload) or {})
client.create_image("ship", 64, 64, reference_images=["style.png"])
assert captured["path"] == "/generate-with-style-v2"
assert captured["payload"]["style_images"][0]["width"] == 64
+68
View File
@@ -0,0 +1,68 @@
import base64
from pathlib import Path
import pytest
from polza import PolzaClient, PolzaError
def test_reference_payload_accepts_url_and_data_uri():
assert PolzaClient.reference_payload("https://example.com/style.png") == {
"type": "url",
"data": "https://example.com/style.png",
}
data_uri = "data:image/png;base64,AAAA"
assert PolzaClient.reference_payload(data_uri) == {"type": "base64", "data": data_uri}
def test_reference_payload_encodes_local_image(tmp_path):
path = tmp_path / "style.png"
path.write_bytes(b"image-bytes")
payload = PolzaClient.reference_payload(str(path))
assert payload["type"] == "base64"
assert payload["data"].startswith("data:image/png;base64,")
assert base64.b64decode(payload["data"].split(",", 1)[1]) == b"image-bytes"
def test_reference_payload_rejects_unknown_path():
with pytest.raises(PolzaError, match="does not exist"):
PolzaClient.reference_payload("missing-style.png")
def test_create_image_sends_references_and_variants(monkeypatch):
client = PolzaClient(api_key="test-key")
captured = {}
def fake_request(method, path, payload=None):
captured.update(method=method, path=path, payload=payload)
return {"id": "gen_1", "status": "pending"}
monkeypatch.setattr(client, "_request", fake_request)
client.create_image(
model="seedream-3",
prompt="game sprite",
reference_images=["https://example.com/style.png"],
count=3,
aspect_ratio="1:1",
seed=42,
wait=False,
)
assert captured["method"] == "POST"
assert captured["path"] == "/media"
assert captured["payload"]["async"] is True
assert captured["payload"]["input"]["max_images"] == 3
assert captured["payload"]["input"]["images"][0]["type"] == "url"
def test_image_sources_handles_urls_and_base64():
response = {
"data": [{"url": "https://cdn.example/one.png"}],
"result": {"images": [{"b64_json": "aGVsbG8="}]},
}
assert PolzaClient.image_sources(response) == [
"https://cdn.example/one.png",
"aGVsbG8=",
]
+11
View File
@@ -152,6 +152,17 @@ class TestEnsureDir:
server._ensure_dir("test.png")
class TestPngMetadata:
def test_save_sprite_embeds_prompt_and_provenance(self, tmp_path):
path = tmp_path / "sprite.png"
image = Image.new("RGBA", (4, 4), (1, 2, 3, 255))
server._save_sprite(image, str(path), "blue scout", {"provider": "test"})
with Image.open(path) as saved:
assert saved.text["prompt"] == "blue scout"
assert '"provider": "test"' in saved.text["imagen"]
class TestEnvPaths:
def test_default_model_dir(self):
assert "models" in server.MODEL_DIR