Add cloud sprite providers and browser demos
This commit is contained in:
@@ -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
|
||||
@@ -7,6 +7,7 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
.env
|
||||
dist/
|
||||
build/
|
||||
|
||||
|
||||
@@ -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 1–10 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 |
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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 1–4 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
|
||||
1–4 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 16–512.
|
||||
# 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 1–4 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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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=",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user