Files
SpriteTool/sprite_tool_fullstack.py

202 lines
8.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.responses import HTMLResponse, FileResponse
from PIL import Image
import io, math, json, os
app = FastAPI()
BASE = os.getcwd()
OUTPUT_DIR = os.path.join(BASE, 'output')
os.makedirs(OUTPUT_DIR, exist_ok=True)
HTML = '''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.tailwindcss.com"></script>
<style>
.pixelated { image-rendering: pixelated; }
/* 統一捲軸樣式,避免 Webkit 預設樣式不一 */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1e293b; }
::-webkit-scrollbar-thumb { background: #475569; border-radius: 4px; }
</style>
</head>
<body class="bg-slate-900 text-white p-4">
<div class="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="bg-slate-800 p-4 rounded-2xl space-y-2">
<h1 class="text-xl font-bold">Sprite Tool Suite</h1>
<div class="bg-blue-900/50 p-3 rounded-lg border border-blue-700/50">
<p class="text-sm font-semibold text-blue-200">Total Tiles: <span id="totalCount" class="text-xl text-white">0</span></p>
</div>
<p class="text-xs text-slate-400">操作直接拖拽平移。Cmd+點擊選取兩點定一區。</p>
<input id="file" type="file" accept="image/*" class="w-full">
<div class="grid grid-cols-2 gap-2">
<input id="cols" type="number" value="64" class="p-2 rounded text-black">
<input id="rows" type="number" value="95" class="p-2 rounded text-black">
<input id="outCols" type="number" value="6" class="p-2 rounded text-black">
<input id="outRows" type="number" value="6" class="p-2 rounded text-black">
</div>
<input id="name" value="output.png" class="w-full p-2 rounded text-black">
<div class="flex gap-2">
<button onclick="undo()" class="flex-1 py-2 bg-slate-600 rounded">Undo</button>
<button onclick="clearAll()" class="flex-1 py-2 bg-slate-600 rounded">Clear</button>
<button onclick="exportFile()" class="flex-1 py-2 bg-blue-600 rounded font-bold">Export</button>
</div>
<textarea id="log" class="w-full h-80 text-xs font-mono bg-black/50 p-2 rounded border border-slate-700 text-green-400" spellcheck="false"></textarea>
</div>
<div class="lg:col-span-2 bg-slate-800 p-4 rounded-2xl overflow-hidden relative">
<canvas id="cv" class="border border-slate-600 w-full"></canvas>
</div>
</div>
<script>
let img=null, fileBlob=null, regions=[], scale=1;
let offX=0, offY=0, isPanning=false, lastMouseX=0, lastMouseY=0;
let pendingPoint=null;
const cv=document.getElementById('cv'), ctx=cv.getContext('2d'), logArea=document.getElementById('log'), countDisplay=document.getElementById('totalCount');
function n(id){return parseInt(document.getElementById(id).value||0)}
// 計算總格數並更新 UI
function updateUI(){
logArea.value = JSON.stringify(regions, null, 2);
let total = 0;
regions.forEach(r => {
total += (r.x2 - r.x1 + 1) * (r.y2 - r.y1 + 1);
});
countDisplay.innerText = total;
}
logArea.oninput = () => {
try {
const val = JSON.parse(logArea.value);
if(Array.isArray(val)) { regions = val; draw(); updateUI(); }
} catch(e) {}
};
function draw(){
if(!img)return;
cv.width = cv.parentElement.clientWidth;
cv.height = 700;
ctx.clearRect(0,0,cv.width,cv.height);
ctx.save();
ctx.translate(offX, offY);
ctx.scale(scale, scale);
ctx.drawImage(img,0,0);
const cols=n('cols'), rows=n('rows');
if(cols && rows){
const cw=img.width/cols, ch=img.height/rows;
ctx.strokeStyle='rgba(255,255,255,0.1)';
ctx.lineWidth = 1/scale;
for(let x=0;x<=cols;x++){ctx.beginPath();ctx.moveTo(x*cw,0);ctx.lineTo(x*cw,img.height);ctx.stroke()}
for(let y=0;y<=rows;y++){ctx.beginPath();ctx.moveTo(0,y*ch);ctx.lineTo(img.width,y*ch);ctx.stroke()}
regions.forEach(r=>{
ctx.fillStyle='rgba(59,130,246,0.3)';
ctx.fillRect(r.x1*cw, r.y1*ch, (r.x2-r.x1+1)*cw, (r.y2-r.y1+1)*ch);
ctx.strokeStyle='rgba(59,130,246,1)';
ctx.strokeRect(r.x1*cw, r.y1*ch, (r.x2-r.x1+1)*cw, (r.y2-r.y1+1)*ch);
});
if(pendingPoint){
ctx.fillStyle='rgba(16,185,129,0.5)';
ctx.fillRect(pendingPoint.x*cw, pendingPoint.y*ch, cw, ch);
}
}
ctx.restore();
}
function getCell(e){
const rect=cv.getBoundingClientRect();
const lx = (e.clientX - rect.left - offX) / scale;
const ly = (e.clientY - rect.top - offY) / scale;
const cw=img.width/n('cols'), ch=img.height/n('rows');
return {
x: Math.floor(Math.max(0, Math.min(img.width-1, lx)) / cw),
y: Math.floor(Math.max(0, Math.min(img.height-1, ly)) / ch)
};
}
cv.onmousedown = e => {
if(!img) return;
if(e.metaKey || e.ctrlKey) {
const p = getCell(e);
if(!pendingPoint) { pendingPoint = p; }
else {
regions.push({
x1: Math.min(pendingPoint.x, p.x), y1: Math.min(pendingPoint.y, p.y),
x2: Math.max(pendingPoint.x, p.x), y2: Math.max(pendingPoint.y, p.y)
});
pendingPoint = null;
updateUI();
}
draw();
} else {
isPanning = true; lastMouseX = e.clientX; lastMouseY = e.clientY;
cv.style.cursor = 'grabbing';
}
};
window.onmousemove = e => { if(isPanning){ offX += (e.clientX-lastMouseX); offY += (e.clientY-lastMouseY); lastMouseX=e.clientX; lastMouseY=e.clientY; draw(); }};
window.onmouseup = () => { isPanning = false; cv.style.cursor = 'default'; };
cv.onwheel = e => {
e.preventDefault();
const oldS = scale; scale = Math.max(0.1, Math.min(10, scale + (e.deltaY<0?0.1:-0.1)));
const rect = cv.getBoundingClientRect();
const mx = e.clientX-rect.left, my = e.clientY-rect.top;
offX = mx - (mx-offX)*(scale/oldS); offY = my - (my-offY)*(scale/oldS);
draw();
};
document.getElementById('file').onchange = e => {
fileBlob = e.target.files[0];
const url = URL.createObjectURL(fileBlob);
img = new Image();
img.onload = () => { offX=0; offY=0; scale=1; draw(); };
img.src = url;
};
function undo(){ if(pendingPoint) pendingPoint=null; else regions.pop(); updateUI(); draw(); }
function clearAll(){ regions=[]; pendingPoint=null; updateUI(); draw(); }
async function exportFile(){
if(!fileBlob || regions.length === 0) return alert('請先選擇檔案並選取區域');
const fd=new FormData();
['file','cols','rows','outCols','outRows','name'].forEach(k=>fd.append(k, k==='file'?fileBlob:document.getElementById(k).value));
fd.append('regions', JSON.stringify(regions));
const r = await fetch('export', {method:'POST', body:fd});
if(!r.ok) return alert(await r.text());
const blob = await r.blob();
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = document.getElementById('name').value; a.click();
}
</script></body></html>'''
@app.get('/', response_class=HTMLResponse)
def home():
return HTML
@app.post('/export')
async def export(file: UploadFile = File(...), cols: int = Form(...), rows: int = Form(...), outCols: int = Form(...), outRows: int = Form(...), name: str = Form('output.png'), regions: str = Form(...)):
data = await file.read()
img = Image.open(io.BytesIO(data)).convert('RGBA')
cw, ch = img.width // cols, img.height // rows
reg_list = json.loads(regions)
tiles = []
for r in reg_list:
for y in range(r['y1'], r['y2'] + 1):
for x in range(r['x1'], r['x2'] + 1):
tiles.append((x, y))
if not tiles: return HTMLResponse('No tiles selected', status_code=400)
needed_rows = math.ceil(len(tiles) / outCols)
out = Image.new('RGBA', (outCols * cw, needed_rows * ch), (0, 0, 0, 0))
for i, (tx, ty) in enumerate(tiles):
tile = img.crop((tx * cw, ty * ch, (tx + 1) * cw, (ty + 1) * ch))
out.paste(tile, ((i % outCols) * cw, (i // outCols) * ch))
path = os.path.join(OUTPUT_DIR, name)
out.save(path)
return FileResponse(path, filename=name, media_type='image/png')
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)