Initial commit: sprite tool project

This commit is contained in:
2026-04-22 14:47:03 +08:00
commit 2d22d01d5b
4 changed files with 776 additions and 0 deletions

214
shiny_maker.py Normal file
View File

@@ -0,0 +1,214 @@
import traceback
from fastapi import FastAPI, UploadFile, File, Form, Request
from fastapi.responses import StreamingResponse, HTMLResponse, FileResponse, JSONResponse
import cv2
import numpy as np
from PIL import Image
import io, os, base64
app = FastAPI()
BASE = os.getcwd()
OUTPUT_DIR = os.path.join(BASE, 'shiny_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-4xl mx-auto space-y-6">
<header class="border-b border-slate-700 pb-4">
<h1 class="text-3xl font-bold text-blue-400">Shiny Monster Maker Pro</h1>
<p class="text-slate-400">全維度色違調整工具 (HSV 模式)</p>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="bg-slate-800 p-6 rounded-2xl space-y-6">
<div class="space-y-2">
<label class="text-sm font-medium text-slate-300">1. 上傳原始圖片</label>
<input id="file" type="file" accept="image/*" class="w-full text-sm text-slate-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-500">
</div>
<div class="space-y-4">
<div class="flex justify-between text-sm">
<label class="font-medium text-slate-300">2. 色調偏移 (Hue Shift)</label>
<span id="hueVal" class="text-blue-400 font-mono">0</span>
</div>
<input id="hueRange" type="range" min="0" max="180" value="0" class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500">
</div>
<div class="space-y-4">
<div class="flex justify-between text-sm">
<label class="font-medium text-slate-300">3. 飽和度倍率 (Saturation)</label>
<span id="satScaleVal" class="text-blue-400 font-mono">1.0</span>
</div>
<input id="satScale" type="range" min="0" max="2" step="0.1" value="1.0" class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-emerald-500">
</div>
<div class="space-y-4">
<div class="flex justify-between text-sm">
<label class="font-medium text-slate-300">4. 亮度倍率 (Brightness)</label>
<span id="valScaleVal" class="text-blue-400 font-mono">1.0</span>
</div>
<input id="valScale" type="range" min="0" max="2" step="0.1" value="1.0" class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500">
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-slate-300">5. 飽和度過濾 (Saturation Mask)</label>
<input id="satMin" type="range" min="0" max="100" value="30" class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-slate-500">
<p class="text-[10px] text-slate-500">避免影響灰色/黑色區塊</p>
</div>
<div class="flex gap-2 pt-4">
<button onclick="processImage()" class="flex-1 py-4 bg-blue-600 hover:bg-blue-500 rounded-xl font-bold shadow-lg transition-all active:scale-95">更新預覽</button>
<button onclick="downloadImage()" id="btnDownload" class="px-6 py-4 bg-slate-700 hover:bg-slate-600 rounded-xl text-sm hidden">下載</button>
</div>
</div>
<div class="bg-slate-800 p-6 rounded-2xl flex flex-col items-center justify-center border-2 border-dashed border-slate-700">
<p id="placeholder" class="text-slate-500 text-sm font-mono italic">AWAITING IMAGE...</p>
<img id="preview" class="max-w-full h-auto rounded shadow-2xl pixelated hidden">
</div>
</div>
</div>
<script>
const hueRange = g('hueRange'), satMin = g('satMin'), satScale = g('satScale'), valScale = g('valScale');
const hueVal = g('hueVal'), satScaleVal = g('satScaleVal'), valScaleVal = g('valScaleVal');
const preview = g('preview'), placeholder = g('placeholder'), btnDownload = g('btnDownload');
let currentFile = null, timer = null;
function g(id){ return document.getElementById(id); }
// 事件綁定
hueRange.oninput = () => { hueVal.innerText = hueRange.value; throttledProcess(); };
satScale.oninput = () => { satScaleVal.innerText = satScale.value; throttledProcess(); };
valScale.oninput = () => { valScaleVal.innerText = valScale.value; throttledProcess(); };
satMin.oninput = () => { throttledProcess(); };
document.getElementById('file').onchange = (e) => {
currentFile = e.target.files[0];
if (currentFile) processImage();
};
function throttledProcess() {
if (timer) clearTimeout(timer);
timer = setTimeout(() => { processImage(); }, 50);
}
async function processImage() {
if (!currentFile) return;
const fd = new FormData();
fd.append('file', currentFile);
fd.append('hue_shift', hueRange.value);
fd.append('sat_min', satMin.value);
fd.append('sat_scale', satScale.value);
fd.append('val_scale', valScale.value);
try {
const r = await fetch('process_shiny', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Backend process failed');
const blob = await r.blob();
if (preview.src) URL.revokeObjectURL(preview.src);
preview.src = URL.createObjectURL(blob);
preview.classList.remove('hidden');
placeholder.classList.add('hidden');
btnDownload.classList.remove('hidden');
} catch (e) {
console.error(e);
}
}
function downloadImage() {
const a = document.createElement('a');
a.href = preview.src;
a.download = `shiny_h${hueRange.value}_s${satScale.value}_v${valScale.value}.png`;
a.click();
}
</script>
<style>.pixelated { image-rendering: pixelated; }</style>
</body></html>'''
@app.get('/', response_class=HTMLResponse)
def home():
return HTML
@app.middleware("http")
async def catch_exceptions_middleware(request: Request, call_next):
try:
return await call_next(request)
except Exception as e:
# 在終端機印出完整的錯誤追蹤
traceback.print_exc()
return JSONResponse(
status_code=500,
content={"message": str(e), "traceback": traceback.format_exc()}
)
@app.post('/process_shiny')
async def process_shiny(
file: UploadFile = File(...),
hue_shift: int = Form(...),
sat_min: int = Form(...),
sat_scale: float = Form(...), # 新增:飽和度倍率
val_scale: float = Form(...) # 新增:亮度倍率
):
try:
data = await file.read()
nparr = np.frombuffer(data, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_UNCHANGED)
if img is None: return JSONResponse(status_code=400, content={"message": "解碼失敗"})
# 頻道判斷
if img.ndim == 2:
bgr = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR); alpha = None
elif img.shape[2] == 3:
bgr = img; alpha = None
else:
bgr = img[:, :, :3]; alpha = img[:, :, 3]
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV).astype(np.float32)
h, s, v = cv2.split(hsv)
# 遮罩邏輯
mask = s > (float(sat_min) * 2.55)
# --- 核心變色邏輯 ---
# 1. 色相旋轉
h[mask] = (h[mask] + float(hue_shift)) % 180
# 2. 飽和度調整 (乘法運算並限制範圍 0-255)
s[mask] = np.clip(s[mask] * sat_scale, 0, 255)
# 3. 亮度調整 (乘法運算並限制範圍 0-255)
v[mask] = np.clip(v[mask] * val_scale, 0, 255)
# 合併與轉換回 BGR
hsv_new = cv2.merge([h, s, v]).astype(np.uint8)
bgr_new = cv2.cvtColor(hsv_new, cv2.COLOR_HSV2BGR)
if alpha is not None:
res = cv2.merge([bgr_new, alpha])
else:
res = bgr_new
_, buffer = cv2.imencode('.png', res)
return StreamingResponse(io.BytesIO(buffer.tobytes()), media_type='image/png')
except Exception as e:
traceback.print_exc()
return JSONResponse(status_code=500, content={"message": str(e)})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

202
sprite_tool_fullstack.py Normal file
View File

@@ -0,0 +1,202 @@
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)

278
sprite_webtool.py Normal file
View File

@@ -0,0 +1,278 @@
import zipfile
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-4 gap-4">
<div class="lg:col-span-1 bg-slate-800 p-4 rounded-2xl space-y-4">
<h1 class="text-xl font-bold border-b border-slate-700 pb-2">Sprite Picker</h1>
<div class="bg-blue-900/30 p-3 rounded-lg border border-blue-700/50">
<p class="text-xs text-blue-200 mb-1">Total Selected</p>
<span id="totalCount" class="text-2xl font-bold">0</span>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-sm">WH:</span>
<input id="wh" type="number" value="96" class="w-full p-2 rounded bg-slate-700 text-white border border-slate-600">
</div>
<div class="flex items-center gap-2">
<span class="text-sm">Cols:</span>
<input id="outCols" type="number" value="10" class="w-full p-2 rounded bg-slate-700 text-white border border-slate-600">
</div>
<input id="name" value="sprites_export.png" class="w-full p-2 rounded bg-slate-700 text-white border border-slate-600">
</div>
<input id="file" type="file" accept="image/*" class="w-full text-xs">
<button onclick="exportFile()" class="w-full py-3 bg-blue-600 hover:bg-blue-500 rounded-xl font-bold transition">Export Image</button>
<div class="space-y-2">
<label class="text-xs text-slate-400">Selected (Click image to remove)</label>
<div id="previewList" class="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto p-2 bg-black/20 rounded"></div>
</div>
</div>
<div class="lg:col-span-3 bg-slate-800 p-4 rounded-2xl overflow-hidden relative min-h-[700px]">
<canvas id="cv" class="border border-slate-700 w-full cursor-none"></canvas>
</div>
</div>
<script>
let img=null, fileBlob=null, picks=[], scale=1, offX=0, offY=0;
let isPanning=false, lastMouseX=0, lastMouseY=0;
let mousePos = {lx:0, ly:0};
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
const previewList = document.getElementById('previewList');
function n(id){return parseInt(document.getElementById(id).value||0)}
function draw(){
if(!img)return;
cv.width = cv.parentElement.clientWidth;
cv.height = 750;
ctx.clearRect(0,0,cv.width,cv.height);
ctx.save();
ctx.translate(offX, offY);
ctx.scale(scale, scale);
ctx.drawImage(img,0,0);
// 繪製標記
picks.forEach((p, i)=>{
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2/scale;
ctx.strokeRect(p.x, p.y, p.w, p.h);
ctx.fillStyle = 'rgba(59,130,246,0.2)';
ctx.fillRect(p.x, p.y, p.w, p.h);
});
// 繪製預覽框 (僅在按住 Cmd/Ctrl 時顯示為選取色)
const wh = n('wh');
const rectX = mousePos.lx - wh/2;
const rectY = mousePos.ly - wh/2;
// 1. 先畫一層半透明的黑色實線作為底色,確保在亮色背景下也能看見
ctx.setLineDash([]); // 實線
ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
ctx.lineWidth = 3 / scale; // 稍微粗一點
ctx.strokeRect(rectX, rectY, wh, wh);
// 2. 繪製主要的虛線層
ctx.setLineDash([8 / scale, 4 / scale]); // 增加虛線間隔,使其更明顯
// 按住 Cmd/Ctrl 時顯示亮紅色,否則顯示亮白色
ctx.strokeStyle = window.isCmdPressed ? '#ff1111' : '#ffffff';
ctx.lineWidth = 2 / scale;
ctx.strokeRect(rectX, rectY, wh, wh);
ctx.restore();
ctx.restore();
}
// 監測鍵盤
window.isCmdPressed = false;
window.onkeydown = e => { if(e.metaKey || e.ctrlKey) { window.isCmdPressed = true; draw(); } };
window.onkeyup = e => { window.isCmdPressed = false; draw(); };
cv.onmousemove = e => {
const rect = cv.getBoundingClientRect();
mousePos.lx = (e.clientX - rect.left - offX) / scale;
mousePos.ly = (e.clientY - rect.top - offY) / scale;
if(isPanning){
offX += (e.clientX - lastMouseX);
offY += (e.clientY - lastMouseY);
lastMouseX = e.clientX;
lastMouseY = e.clientY;
}
draw();
};
cv.onmousedown = e => {
if(!img) return;
if(e.metaKey || e.ctrlKey){
// 選取模式
const wh = n('wh');
const pick = { x: Math.round(mousePos.lx - wh/2), y: Math.round(mousePos.ly - wh/2), w: wh, h: wh };
picks.push(pick);
updateUI();
} else {
// 平移模式
isPanning = true;
lastMouseX = e.clientX;
lastMouseY = e.clientY;
}
draw();
};
window.onmouseup = () => isPanning = false;
cv.onwheel = e => {
e.preventDefault();
const oldS = scale;
// 每次滾動縮放 5%
const factor = e.deltaY < 0 ? 1.05 : 0.95;
scale = Math.max(0.1, Math.min(10, scale * factor));
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();
};
function updateUI(){
document.getElementById('totalCount').innerText = picks.length;
previewList.innerHTML = '';
picks.forEach((p, i) => {
const pcv = document.createElement('canvas');
pcv.width = p.w; pcv.height = p.h;
pcv.getContext('2d').drawImage(img, p.x, p.y, p.w, p.h, 0, 0, p.w, p.h);
const div = document.createElement('div');
div.className = "relative group cursor-pointer border border-slate-600 rounded overflow-hidden hover:border-red-500";
div.onclick = () => { picks.splice(i, 1); updateUI(); draw(); };
div.innerHTML = `<img src="${pcv.toDataURL()}" class="w-full h-auto pixelated"><div class="absolute inset-0 bg-red-500/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-xs">Remove</div>`;
previewList.appendChild(div);
});
}
document.getElementById('file').onchange = e => {
fileBlob = e.target.files[0];
img = new Image();
img.onload = () => { offX=0; offY=0; scale=1; draw(); };
img.src = URL.createObjectURL(fileBlob);
};
async function exportFile(){
if(picks.length === 0) return alert('No selections');
const fd = new FormData();
fd.append('file', fileBlob);
fd.append('outCols', n('outCols'));
fd.append('name', document.getElementById('name').value);
fd.append('picks', JSON.stringify(picks));
const r = await fetch('export_picker', {method:'POST', body:fd});
if(!r.ok) return alert(await r.text());
const blob = await r.blob();
const contentType = r.headers.get('Content-Type');
const a = document.createElement('a');
// 根據回傳類型決定副檔名
let fileName = document.getElementById('name').value;
if (contentType === 'application/zip') {
fileName = fileName.replace('.png', '.zip');
}
a.href = URL.createObjectURL(blob);
a.download = fileName;
a.click();
}
</script>
<style>.pixelated { image-rendering: pixelated; }</style>
</body></html>'''
@app.get('/', response_class=HTMLResponse)
def home():
return HTML
@app.post('/export_picker')
async def export_picker(
file: UploadFile = File(...),
outCols: int = Form(...),
name: str = Form(...),
picks: str = Form(...)
):
data = await file.read()
img = Image.open(io.BytesIO(data)).convert('RGBA')
pick_list = json.loads(picks)
if not pick_list:
return HTMLResponse('None', status_code=400)
tw, th = pick_list[0]['w'], pick_list[0]['h']
base_name = os.path.splitext(name)[0]
# 計算總共可以組成幾張完整的圖片 (捨棄餘數)
num_picks = len(pick_list)
num_images = num_picks // outCols
if num_images == 0:
return HTMLResponse('選取數量不足以構成一列', status_code=400)
# 儲存產出的路徑清單
generated_files = []
for img_idx in range(num_images):
# 建立單張輸出圖 (高度固定為 1 row)
out_img = Image.new('RGBA', (outCols * tw, th), (0, 0, 0, 0))
for col_idx in range(outCols):
p_idx = img_idx * outCols + col_idx
p = pick_list[p_idx]
box = (p['x'], p['y'], p['x'] + p['w'], p['y'] + p['h'])
tile = img.crop(box)
out_img.paste(tile, (col_idx * tw, 0))
# 存檔
file_path = os.path.join(OUTPUT_DIR, f"{base_name}_{img_idx+1}.png")
out_img.save(file_path)
generated_files.append(file_path)
# 如果只有一張圖,直接回傳圖片
if len(generated_files) == 1:
return FileResponse(generated_files[0], filename=f"{base_name}.png", media_type='image/png')
# 如果有多張圖,打包成 ZIP
zip_path = os.path.join(OUTPUT_DIR, f"{base_name}_batch.zip")
with zipfile.ZipFile(zip_path, 'w') as zipf:
for f in generated_files:
zipf.write(f, os.path.basename(f))
return FileResponse(zip_path, filename=f"{base_name}.zip", media_type='application/zip')
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

82
tool.py Normal file
View File

@@ -0,0 +1,82 @@
import uvicorn
import re
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
# 匯入原本的三個檔案
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
app = FastAPI(title="Game Dev Suite")
# --- 定義導覽列 HTML ---
NAVBAR_HTML = """
<nav class="bg-slate-800 border-b border-slate-700 px-6 py-3 flex items-center gap-6 sticky top-0 z-50 shadow-lg font-sans">
<span class="font-bold text-blue-400 mr-4">DEV SUITE</span>
<a href="/" class="text-sm text-slate-300 hover:text-blue-400 transition">🏠 Home</a>
<a href="/grid/" class="text-sm text-slate-300 hover:text-blue-400 transition">📏 Grid Tool</a>
<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>
</nav>
"""
class NavbarMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# 排除首頁,避免重複注入
if request.url.path == "/":
return response
# 針對所有子工具的 HTML 回傳進行注入
if "text/html" in response.headers.get("content-type", ""):
body = b""
async for chunk in response.body_iterator:
body += chunk
html_content = body.decode("utf-8")
# 使用正則表達式匹配 <body> 標籤,不論它有沒有帶 class 或其他屬性
# 這會匹配 <body ...> 並在其後方插入 Navbar
new_content = re.sub(r'(<body[^>]*>)', r'\1' + NAVBAR_HTML, html_content, flags=re.IGNORECASE)
return HTMLResponse(content=new_content, status_code=response.status_code)
return response
app.add_middleware(NavbarMiddleware)
# --- 掛載子應用程式 ---
app.mount("/grid", grid_app)
app.mount("/picker", picker_app)
app.mount("/shiny", shiny_app)
# 首頁入口
@app.get("/", response_class=HTMLResponse)
async def index():
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-900 text-white">
{NAVBAR_HTML}
<div class="flex flex-col items-center justify-center min-h-[80vh] space-y-6">
<h1 class="text-4xl font-bold text-blue-400">Game Developer Tool Suite</h1>
<p class="text-slate-400">請從上方導覽列選擇要使用的工具</p>
<div class="grid grid-cols-3 gap-6 pt-10">
<div class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center">📏 Grid Tool</div>
<div class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center">🎯 Picker Tool</div>
<div class="p-6 bg-slate-800 rounded-2xl border border-slate-700 text-center">✨ Shiny Maker</div>
</div>
</div>
</body>
</html>
"""
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)