diff --git a/folder_sprite_builder.py b/folder_sprite_builder.py new file mode 100644 index 0000000..bbe6e1c --- /dev/null +++ b/folder_sprite_builder.py @@ -0,0 +1,310 @@ +import io +import math +import os +import re +from fastapi import FastAPI, File, Form, UploadFile +from fastapi.responses import HTMLResponse, PlainTextResponse, Response +from PIL import Image + +app = FastAPI() + +ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} +RESAMPLE_LANCZOS = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.BICUBIC) + + +def natural_name_key(full_name: str) -> tuple[list[object], str]: + normalized = (full_name or "").replace("\\", "/") + base = os.path.basename(normalized) + parts = re.split(r"(\d+)", base.lower()) + key: list[object] = [int(p) if p.isdigit() else p for p in parts] + return key, normalized.lower() + +HTML = """ + + + + Folder Sprite Builder + + + +
+
+

🗂️ Folder Sprite Builder

+

選擇資料夾後,依 Columns 與總寬度自動縮放並合併為 Sprite 圖

+
+ +
+
+ + +

支援 png/jpg/jpeg/webp/bmp/gif

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + + +
+ +
+

目前資料夾中的圖片

+
+
+
+ + + + +""" + + +@app.get("/", response_class=HTMLResponse) +async def index(): + return HTML + + +@app.post("/build") +async def build_folder_sprite( + files: list[UploadFile] = File(...), + cols: int = Form(...), + total_width: int = Form(...), + output_name: str = Form("folder_sprite.png"), +): + if cols <= 0: + return PlainTextResponse("Columns 必須大於 0", status_code=400) + if total_width <= 0: + return PlainTextResponse("總寬度必須大於 0", status_code=400) + + tile_w = total_width // cols + if tile_w <= 0: + return PlainTextResponse("總寬度過小,請提高總寬度或降低 Columns", status_code=400) + + image_files: list[UploadFile] = [] + for f in files: + _, ext = os.path.splitext((f.filename or "").lower()) + if ext in ALLOWED_EXTENSIONS: + image_files.append(f) + + if not image_files: + return PlainTextResponse("找不到可用圖片檔", status_code=400) + + image_files.sort(key=lambda f: natural_name_key(f.filename or "")) + + resized_images: list[Image.Image] = [] + resized_heights: list[int] = [] + + for f in image_files: + try: + data = await f.read() + img = Image.open(io.BytesIO(data)).convert("RGBA") + except Exception: + continue + + if img.width <= 0 or img.height <= 0: + continue + + ratio = min(1.0, tile_w / float(img.width)) + new_w = max(1, int(round(img.width * ratio))) + new_h = max(1, int(round(img.height * ratio))) + resized = img.resize((new_w, new_h), RESAMPLE_LANCZOS) + + resized_images.append(resized) + resized_heights.append(new_h) + + if not resized_images: + return PlainTextResponse("圖片讀取失敗,請確認檔案格式", status_code=400) + + cell_h = max(resized_heights) + rows = math.ceil(len(resized_images) / cols) + out_h = rows * cell_h + + sheet = Image.new("RGBA", (total_width, out_h), (0, 0, 0, 0)) + + for idx, img in enumerate(resized_images): + row = idx // cols + col = idx % cols + + x = col * tile_w + (tile_w - img.width) // 2 + y = row * cell_h + (cell_h - img.height) // 2 + sheet.paste(img, (x, y), img) + + final_name = output_name.strip() or "folder_sprite.png" + if not final_name.lower().endswith(".png"): + final_name += ".png" + + buf = io.BytesIO() + sheet.save(buf, format="PNG") + buf.seek(0) + + return Response( + content=buf.read(), + media_type="image/png", + headers={"Content-Disposition": f'attachment; filename="{final_name}"'}, + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("folder_sprite_builder:app", host="0.0.0.0", port=8005, reload=True) diff --git a/tool.py b/tool.py index ba7653b..a8eef13 100644 --- a/tool.py +++ b/tool.py @@ -12,6 +12,7 @@ from rotate_webtool import app as flipper_app from sprite_merger import app as merger_app from inset_crop_tool import app as inset_app from sprite_splitter import app as splitter_app +from folder_sprite_builder import app as folder_builder_app app = FastAPI(title="Game Dev Suite") @@ -25,6 +26,7 @@ NAVBAR_HTML = """ ✨ Shiny Maker 🔄 Flipper 🧩 Merger + 🗂️ Folder Builder ✂️ Inset Crop 📦 Splitter @@ -58,6 +60,7 @@ app.mount("/picker", picker_app) app.mount("/shiny", shiny_app) app.mount("/flipper", flipper_app) app.mount("/merger", merger_app) +app.mount("/folder-builder", folder_builder_app) app.mount("/inset", inset_app) app.mount("/splitter", splitter_app) @@ -81,6 +84,7 @@ async def index(): ✨ Shiny Maker 🔄 Flipper 🧩 Merger + 🗂️ Folder Builder ✂️ Inset Crop 📦 Splitter