159 lines
6.8 KiB
Python
159 lines
6.8 KiB
Python
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}"'},
|
||
)
|