"""Update the Kirara sprite rectangles in web/index.html from transparent PNGs. Usage: .venv/Scripts/python.exe scripts/build_kirara_sprite_catalog.py """ from pathlib import Path from PIL import Image ROOT = Path(__file__).resolve().parents[1] ASSETS = ROOT / "web" / "uploads" PAGE = ROOT / "web" / "index.html" START = " // Source rectangles come from the four generated transparent 2×2 walk sheets." END = " let kiraraDirection = \"front\";" def frame_boxes(path: Path) -> list[list[int]]: image = Image.open(path).convert("RGBA") width, height = image.size if width < 100 or height < 100: raise ValueError(f"Sprite is unexpectedly small: {path}") boxes = [] for frame in range(4): start = round(frame * width / 4) stop = round((frame + 1) * width / 4) alpha = image.crop((start, 0, stop, height)).getchannel("A") content = alpha.point(lambda value: 255 if value >= 16 else 0).getbbox() if content is None: raise ValueError(f"Empty frame {frame}: {path}") left = max(0, content[0] - 3) top = max(0, content[1] - 3) right = min(stop - start, content[2] + 3) bottom = min(height, content[3] + 3) boxes.append([start + left, top, right - left, bottom - top]) return boxes names = [ *(f"kirara_walk_{direction}" for direction in ("front", "back", "left", "right")), "kirara_eat_rice", "kirara_eat_dango", "kirara_bath_sponge", "kirara_bath_shower", *(f"kirara_idle_{name}" for name in ( "delivery", "reading", "phone", "sleep", "stretch", "groom", "butterfly", "tea", "yarn", "humming")), "kirara_rest_breathe", *(f"kirara_emotion_{name}_{variant}" for name in ( "plus3", "plus1", "minus1", "minus3") for variant in ("a", "b")), ] lines = [ " // Crops follow each generated sprite's transparent silhouette.", " const kiraraSpriteBoxes = {", ] for name in names: boxes = frame_boxes(ASSETS / f"{name}.png") formatted = ", ".join(str(box).replace(" ", "") for box in boxes) lines.append(f' "{name}": [{formatted}],') lines += [ " };", " const kiraraSpriteAsset = name => ({", " src: `/uploads/${name}.png`, boxes: kiraraSpriteBoxes[name]", " });", " const kiraraWalkAssets = Object.fromEntries(", " [\"front\", \"back\", \"left\", \"right\"].map(direction =>", " [direction, kiraraSpriteAsset(`kirara_walk_${direction}`)])", " );", " const kiraraActionAssets = {", " feed: [kiraraSpriteAsset(\"kirara_eat_rice\"), kiraraSpriteAsset(\"kirara_eat_dango\")],", " bath: [kiraraSpriteAsset(\"kirara_bath_sponge\"), kiraraSpriteAsset(\"kirara_bath_shower\")]", " };", " const kiraraIdleAssets = Object.fromEntries(", " [\"delivery\", \"reading\", \"phone\", \"sleep\", \"stretch\",", " \"groom\", \"butterfly\", \"tea\", \"yarn\", \"humming\"].map(type =>", " [type, kiraraSpriteAsset(`kirara_idle_${type}`)])", " );", " const kiraraRestAsset = kiraraSpriteAsset(\"kirara_rest_breathe\");", " const kiraraEmotionAssets = {", ] for delta, word in ((3, "plus3"), (1, "plus1"), (-1, "minus1"), (-3, "minus3")): lines.append( f' "{delta}": [kiraraSpriteAsset("kirara_emotion_{word}_a"), ' f'kiraraSpriteAsset("kirara_emotion_{word}_b")],' ) lines += [" };", ""] text = PAGE.read_text(encoding="utf-8") start = text.find(START) if start < 0: start = text.find(" // Crops follow each generated sprite's transparent silhouette.") end = text.find(END, start) if start < 0 or end < 0: raise ValueError("Kirara asset block was not found") PAGE.write_text(text[:start] + "\n".join(lines) + text[end:], encoding="utf-8") print(f"Updated {len(names)} Kirara sprite sheets in {PAGE}")