Add Sprite Merger tool with web UI and integrate into tool.py
This commit is contained in:
BIN
_grassland.png
Normal file
BIN
_grassland.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
fireant.png
Normal file
BIN
fireant.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
grassland.png
Normal file
BIN
grassland.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
158
sprite_merger.py
Normal file
158
sprite_merger.py
Normal file
@@ -0,0 +1,158 @@
|
||||
import io
|
||||
import base64
|
||||
from fastapi import FastAPI, UploadFile, File, Form
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from PIL import Image
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Sprite Merger</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-slate-900 text-white min-h-screen p-8">
|
||||
<div class="max-w-xl mx-auto space-y-6">
|
||||
<h1 class="text-2xl font-bold text-blue-400">🧩 Sprite Merger</h1>
|
||||
<p class="text-slate-400 text-sm">將來源圖片 merge 進主圖的指定 tile 座標</p>
|
||||
|
||||
<form id="mergeForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 block mb-1">主圖 (Main Sprite Sheet)</label>
|
||||
<input type="file" name="main_image" accept="image/*" required
|
||||
class="w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700 cursor-pointer" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 block mb-1">來源圖 (Source Sprite)</label>
|
||||
<input type="file" name="source_image" accept="image/*" required
|
||||
class="w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:bg-green-600 file:text-white hover:file:bg-green-700 cursor-pointer" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 block mb-1">主圖 Columns 數量</label>
|
||||
<input type="number" name="main_cols" min="1" required placeholder="e.g. 8"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 block mb-1">主圖 Rows 數量</label>
|
||||
<input type="number" name="main_rows" min="1" required placeholder="e.g. 4"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 block mb-1">目標 Column(從 0 開始)</label>
|
||||
<input type="number" name="target_col" min="0" required placeholder="e.g. 4"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 block mb-1">目標 Row(從 0 開始)</label>
|
||||
<input type="number" name="target_row" min="0" required placeholder="e.g. 5"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-300 block mb-1">輸出檔名</label>
|
||||
<input type="text" name="output_name" placeholder="output.png" value="output.png"
|
||||
class="w-full bg-slate-800 border border-slate-600 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-semibold transition">
|
||||
Merge & Download
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="preview" class="hidden space-y-3">
|
||||
<p class="text-green-400 text-sm font-semibold">✅ Merge 完成,預覽如下:</p>
|
||||
<img id="previewImg" class="border border-slate-600 rounded max-w-full" style="image-rendering: pixelated;" />
|
||||
<a id="downloadBtn" class="block text-center py-2 bg-green-600 hover:bg-green-700 rounded-lg font-semibold transition cursor-pointer">
|
||||
⬇ 下載結果
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="errorMsg" class="hidden text-red-400 text-sm"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('mergeForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
document.getElementById('preview').classList.add('hidden');
|
||||
document.getElementById('errorMsg').classList.add('hidden');
|
||||
|
||||
const outputName = formData.get('output_name') || 'output.png';
|
||||
|
||||
const res = await fetch('/merger/merge', { method: 'POST', body: formData });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
document.getElementById('errorMsg').textContent = '❌ 錯誤:' + text;
|
||||
document.getElementById('errorMsg').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = document.getElementById('previewImg');
|
||||
img.src = url;
|
||||
|
||||
const dlBtn = document.getElementById('downloadBtn');
|
||||
dlBtn.onclick = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = outputName;
|
||||
a.click();
|
||||
};
|
||||
document.getElementById('preview').classList.remove('hidden');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return HTML
|
||||
|
||||
|
||||
@app.post("/merge")
|
||||
async def merge_sprites(
|
||||
main_image: UploadFile = File(...),
|
||||
source_image: UploadFile = File(...),
|
||||
main_cols: int = Form(...),
|
||||
main_rows: int = Form(...),
|
||||
target_col: int = Form(...),
|
||||
target_row: int = Form(...),
|
||||
output_name: str = Form("output.png"),
|
||||
):
|
||||
main_data = await main_image.read()
|
||||
source_data = await source_image.read()
|
||||
|
||||
main_img = Image.open(io.BytesIO(main_data)).convert("RGBA")
|
||||
source_img = Image.open(io.BytesIO(source_data)).convert("RGBA")
|
||||
|
||||
main_w, main_h = main_img.size
|
||||
tile_w = main_w // main_cols
|
||||
tile_h = main_h // main_rows
|
||||
|
||||
# 將來源圖縮放至 tile 尺寸
|
||||
source_resized = source_img.resize((tile_w, tile_h), Image.NEAREST)
|
||||
|
||||
paste_x = target_col * tile_w
|
||||
paste_y = target_row * tile_h
|
||||
|
||||
result = main_img.copy()
|
||||
result.paste(source_resized, (paste_x, paste_y), source_resized)
|
||||
|
||||
buf = io.BytesIO()
|
||||
result.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
|
||||
return Response(
|
||||
content=buf.read(),
|
||||
media_type="image/png",
|
||||
headers={"Content-Disposition": f'attachment; filename="{output_name}"'},
|
||||
)
|
||||
4
tool.py
4
tool.py
@@ -9,6 +9,7 @@ from sprite_tool_fullstack import app as grid_app
|
||||
from sprite_webtool import app as picker_app
|
||||
from shiny_maker import app as shiny_app
|
||||
from rotate_webtool import app as flipper_app
|
||||
from sprite_merger import app as merger_app
|
||||
|
||||
app = FastAPI(title="Game Dev Suite")
|
||||
|
||||
@@ -21,6 +22,7 @@ NAVBAR_HTML = """
|
||||
<a href="/picker/" class="text-sm text-slate-300 hover:text-blue-400 transition">🎯 Picker Tool</a>
|
||||
<a href="/shiny/" class="text-sm text-slate-300 hover:text-blue-400 transition">✨ Shiny Maker</a>
|
||||
<a href="/flipper/" class="text-sm text-slate-300 hover:text-blue-400 transition">🔄 Flipper</a>
|
||||
<a href="/merger/" class="text-sm text-slate-300 hover:text-blue-400 transition">🧩 Merger</a>
|
||||
</nav>
|
||||
"""
|
||||
|
||||
@@ -55,6 +57,7 @@ app.mount("/grid", grid_app)
|
||||
app.mount("/picker", picker_app)
|
||||
app.mount("/shiny", shiny_app)
|
||||
app.mount("/flipper", flipper_app)
|
||||
app.mount("/merger", merger_app)
|
||||
|
||||
# 首頁入口
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@@ -76,6 +79,7 @@ async def index():
|
||||
<a href="/picker/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">🎯 Picker Tool</a>
|
||||
<a href="/shiny/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">✨ Shiny Maker</a>
|
||||
<a href="/flipper/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">🔄 Flipper</a>
|
||||
<a href="/merger/" class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center hover:border-blue-500 transition block">🧩 Merger</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user