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
+
+
+
+
+
+
+
+
+
+
+
支援 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