HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/.codex/.tmp/plugins/plugins/game-studio/scripts/render_sprite_preview_sheet.py
#!/usr/bin/env python3
"""Render a simple contact sheet from a directory of normalized sprite frames."""

from __future__ import annotations

import argparse
import math
import re
from pathlib import Path

try:
    from PIL import Image, ImageDraw
except ImportError as exc:  # pragma: no cover
    raise SystemExit(
        "Pillow is required. Install it with `python3 -m pip install pillow`."
    ) from exc


NUMBER_RE = re.compile(r"(\d+)")


def natural_key(path: Path) -> list[int | str]:
    parts: list[int | str] = []
    for chunk in NUMBER_RE.split(path.stem):
        if not chunk:
            continue
        if chunk.isdigit():
            parts.append(int(chunk))
        else:
            parts.append(chunk)
    parts.append(path.suffix)
    return parts


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Render a preview contact sheet from a directory of sprite frames."
    )
    parser.add_argument("--frames-dir", required=True, help="Directory containing PNG frames.")
    parser.add_argument("--out", required=True, help="Output PNG path.")
    parser.add_argument(
        "--columns",
        type=int,
        default=4,
        help="Number of columns in the preview sheet. Default: 4.",
    )
    parser.add_argument(
        "--gap",
        type=int,
        default=8,
        help="Gap between frames in pixels. Default: 8.",
    )
    return parser.parse_args()


def paint_checkerboard(image: Image.Image, tile: int = 16) -> None:
    draw = ImageDraw.Draw(image)
    colors = ((240, 243, 246, 255), (225, 230, 235, 255))
    for top in range(0, image.height, tile):
        for left in range(0, image.width, tile):
            color = colors[((left // tile) + (top // tile)) % 2]
            draw.rectangle((left, top, left + tile, top + tile), fill=color)


def main() -> None:
    args = parse_args()
    if args.columns < 1:
        raise SystemExit("--columns must be at least 1.")
    if args.gap < 0:
        raise SystemExit("--gap cannot be negative.")

    frame_dir = Path(args.frames_dir)
    frames = sorted(frame_dir.glob("*.png"), key=natural_key)
    if not frames:
        raise SystemExit("No PNG frames were found in --frames-dir.")

    images = [Image.open(path).convert("RGBA") for path in frames]
    frame_width = max(image.width for image in images)
    frame_height = max(image.height for image in images)
    rows = math.ceil(len(images) / args.columns)
    sheet_width = args.columns * frame_width + max(0, args.columns - 1) * args.gap
    sheet_height = rows * frame_height + max(0, rows - 1) * args.gap
    sheet = Image.new("RGBA", (sheet_width, sheet_height), (255, 255, 255, 255))
    paint_checkerboard(sheet)

    for index, image in enumerate(images):
        row = index // args.columns
        column = index % args.columns
        left = column * (frame_width + args.gap) + (frame_width - image.width) // 2
        top = row * (frame_height + args.gap) + (frame_height - image.height) // 2
        sheet.alpha_composite(image, (left, top))

    out_path = Path(args.out)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    sheet.save(out_path)


if __name__ == "__main__":
    main()