chore: share game-studio hermes plugin
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a transparent edit canvas around a shipped seed sprite frame."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Pillow is required. Install it with `python3 -m pip install pillow`."
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Upscale a seed sprite with nearest-neighbor sampling and place it into "
|
||||
"the leftmost slot of a larger transparent edit canvas."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--seed", required=True, help="Path to the approved seed frame.")
|
||||
parser.add_argument("--out", required=True, help="Path to the output PNG.")
|
||||
parser.add_argument(
|
||||
"--frames",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Number of horizontal frame slots to reserve. Default: 4.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slot-size",
|
||||
type=int,
|
||||
default=256,
|
||||
help="Size of each square frame slot in pixels. Default: 256.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--canvas-size",
|
||||
type=int,
|
||||
default=1024,
|
||||
help="Size of the square transparent canvas in pixels. Default: 1024.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resize_seed(seed: Image.Image, slot_size: int) -> Image.Image:
|
||||
max_dim = max(seed.size)
|
||||
scale = slot_size / max_dim
|
||||
if scale >= 1:
|
||||
scale = max(1, int(scale))
|
||||
width = max(1, int(round(seed.width * scale)))
|
||||
height = max(1, int(round(seed.height * scale)))
|
||||
return seed.resize((width, height), Image.Resampling.NEAREST)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.frames < 1:
|
||||
raise SystemExit("--frames must be at least 1.")
|
||||
if args.slot_size < 1 or args.canvas_size < 1:
|
||||
raise SystemExit("--slot-size and --canvas-size must be positive.")
|
||||
|
||||
strip_width = args.frames * args.slot_size
|
||||
if strip_width > args.canvas_size or args.slot_size > args.canvas_size:
|
||||
raise SystemExit("Frame slots do not fit inside the requested canvas size.")
|
||||
|
||||
seed = Image.open(args.seed).convert("RGBA")
|
||||
seed = resize_seed(seed, args.slot_size)
|
||||
|
||||
canvas = Image.new("RGBA", (args.canvas_size, args.canvas_size), (0, 0, 0, 0))
|
||||
strip_left = (args.canvas_size - strip_width) // 2
|
||||
strip_top = (args.canvas_size - args.slot_size) // 2
|
||||
slot_left = strip_left
|
||||
slot_top = strip_top
|
||||
paste_x = slot_left + (args.slot_size - seed.width) // 2
|
||||
paste_y = slot_top + (args.slot_size - seed.height) // 2
|
||||
canvas.alpha_composite(seed, (paste_x, paste_y))
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
canvas.save(out_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
152
.hermes/plugins/game-studio/scripts/normalize_sprite_strip.py
Normal file
152
.hermes/plugins/game-studio/scripts/normalize_sprite_strip.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize a raw animation strip into fixed-size transparent frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise SystemExit(
|
||||
"Pillow is required. Install it with `python3 -m pip install pillow`."
|
||||
) from exc
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Extract one horizontal strip into fixed-size frames using a shared "
|
||||
"global scale and bottom-center alignment."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--input", required=True, help="Path to the raw strip image.")
|
||||
parser.add_argument("--out-dir", required=True, help="Output directory for frames.")
|
||||
parser.add_argument(
|
||||
"--frames",
|
||||
type=int,
|
||||
required=True,
|
||||
help="Number of horizontal frames in the strip.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frame-size",
|
||||
type=int,
|
||||
default=64,
|
||||
help="Output square frame size in pixels. Default: 64.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--anchor",
|
||||
help="Optional anchor frame used to stabilize global scale and frame 01 output.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lock-frame1",
|
||||
action="store_true",
|
||||
help="Replace frame 01 with the provided anchor frame after normalization.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--alpha-threshold",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Pixels with alpha above this threshold count as sprite content. Default: 8.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def threshold_bbox(image: Image.Image, alpha_threshold: int) -> tuple[int, int, int, int] | None:
|
||||
alpha = image.getchannel("A").point(lambda value: 255 if value > alpha_threshold else 0)
|
||||
return alpha.getbbox()
|
||||
|
||||
|
||||
def crop_to_content(image: Image.Image, alpha_threshold: int) -> Image.Image | None:
|
||||
bbox = threshold_bbox(image, alpha_threshold)
|
||||
if bbox is None:
|
||||
return None
|
||||
return image.crop(bbox)
|
||||
|
||||
|
||||
def split_strip(strip: Image.Image, frames: int) -> list[Image.Image]:
|
||||
if frames < 1:
|
||||
raise ValueError("frames must be at least 1")
|
||||
step = strip.width / frames
|
||||
slots: list[Image.Image] = []
|
||||
for index in range(frames):
|
||||
left = int(round(index * step))
|
||||
right = int(round((index + 1) * step))
|
||||
slots.append(strip.crop((left, 0, right, strip.height)))
|
||||
return slots
|
||||
|
||||
|
||||
def max_content_size(images: Iterable[Image.Image | None]) -> tuple[int, int]:
|
||||
widths: list[int] = []
|
||||
heights: list[int] = []
|
||||
for image in images:
|
||||
if image is None:
|
||||
continue
|
||||
widths.append(image.width)
|
||||
heights.append(image.height)
|
||||
if not widths or not heights:
|
||||
raise SystemExit("No sprite content was detected in the provided strip.")
|
||||
return max(widths), max(heights)
|
||||
|
||||
|
||||
def compose_frame(
|
||||
image: Image.Image | None,
|
||||
frame_size: int,
|
||||
scale: float,
|
||||
) -> Image.Image:
|
||||
canvas = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
|
||||
if image is None:
|
||||
return canvas
|
||||
|
||||
width = max(1, int(round(image.width * scale)))
|
||||
height = max(1, int(round(image.height * scale)))
|
||||
resized = image.resize((width, height), Image.Resampling.NEAREST)
|
||||
offset_x = (frame_size - width) // 2
|
||||
offset_y = frame_size - height
|
||||
canvas.alpha_composite(resized, (offset_x, offset_y))
|
||||
return canvas
|
||||
|
||||
|
||||
def load_anchor(path: str | None, alpha_threshold: int) -> tuple[Image.Image | None, Image.Image | None]:
|
||||
if path is None:
|
||||
return None, None
|
||||
anchor = Image.open(path).convert("RGBA")
|
||||
cropped = crop_to_content(anchor, alpha_threshold)
|
||||
return anchor, cropped
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.frames < 1:
|
||||
raise SystemExit("--frames must be at least 1.")
|
||||
if args.frame_size < 1:
|
||||
raise SystemExit("--frame-size must be positive.")
|
||||
if args.lock_frame1 and not args.anchor:
|
||||
raise SystemExit("--lock-frame1 requires --anchor.")
|
||||
|
||||
strip = Image.open(args.input).convert("RGBA")
|
||||
slots = split_strip(strip, args.frames)
|
||||
contents = [crop_to_content(slot, args.alpha_threshold) for slot in slots]
|
||||
anchor_image, anchor_content = load_anchor(args.anchor, args.alpha_threshold)
|
||||
max_width, max_height = max_content_size([*contents, anchor_content])
|
||||
scale = min(args.frame_size / max_width, args.frame_size / max_height)
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for index, content in enumerate(contents, start=1):
|
||||
if index == 1 and args.lock_frame1:
|
||||
assert anchor_image is not None
|
||||
if anchor_image.width == args.frame_size and anchor_image.height == args.frame_size:
|
||||
frame = anchor_image
|
||||
else:
|
||||
frame = compose_frame(anchor_content, args.frame_size, scale)
|
||||
else:
|
||||
frame = compose_frame(content, args.frame_size, scale)
|
||||
frame.save(out_dir / f"{index:02d}.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user