Add Sprite Merger tool with web UI and integrate into tool.py
This commit is contained in:
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}"'},
|
||||
)
|
||||
Reference in New Issue
Block a user