Initial commit: sprite tool project
This commit is contained in:
214
shiny_maker.py
Normal file
214
shiny_maker.py
Normal 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
202
sprite_tool_fullstack.py
Normal 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
278
sprite_webtool.py
Normal 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
82
tool.py
Normal 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)
|
||||
Reference in New Issue
Block a user