|
"""Build the `bufo-motion.codex-pet` bundle from all-the-bufo's animated GIFs. |
|
|
|
Downloads the nine source GIFs, bakes their frames into a Codex-layout |
|
spritesheet, and writes the `pet.json` manifest alongside it. Each GIF's authored |
|
per-frame timing is carried through, so the pet animates the way the original |
|
does rather than at a flat sheet-wide fps. |
|
|
|
uv run --with pillow python bufo-motion-build.py |
|
""" |
|
|
|
from __future__ import annotations |
|
|
|
import json |
|
import urllib.request |
|
from pathlib import Path |
|
|
|
from PIL import Image, ImageSequence |
|
|
|
RAW = "https://raw.githubusercontent.com/knobiknows/all-the-bufo/main/all-the-bufo" |
|
CACHE = Path("gifs") |
|
BUNDLE = Path("bufo-motion.codex-pet") |
|
|
|
# Codex's native frame size. Eight columns is its default; this sheet is widened |
|
# to 16 so the longer GIFs fit without being thinned out. |
|
FRAME_W, FRAME_H = 192, 208 |
|
COLUMNS = 16 |
|
# How much of the frame the bufo fills, and how far above the bottom edge it sits. |
|
TARGET_H = 168 |
|
BASELINE = FRAME_H - 8 |
|
|
|
# GIFs authored at 0-20ms per frame mean "as fast as possible"; browsers and Slack |
|
# render those at 100ms, so match that rather than baking in a strobe. |
|
MIN_FRAME_MS = 30 |
|
FALLBACK_FRAME_MS = 100 |
|
|
|
# Row order is fixed — a host looks rows up by name, but the row index is what |
|
# lands in the manifest. |
|
CAST: dict[str, str] = { |
|
"idle": "bufo-vibe", |
|
"running-right": "bufo-run-right", |
|
"running-left": "bufo-run", |
|
"waving": "bufo-wave", |
|
"jumping": "bufo-yay", |
|
"failed": "bufo-crying", |
|
"waiting": "bufo-is-it-done", |
|
"running": "bufo-keyboard", |
|
"review": "bufo-done-check", |
|
} |
|
|
|
|
|
def fetch(name: str) -> Path: |
|
"""Download one source GIF, caching it next to the script.""" |
|
CACHE.mkdir(exist_ok=True) |
|
path = CACHE / f"{name}.gif" |
|
if not path.exists(): |
|
print(f"fetching {name}.gif") |
|
with urllib.request.urlopen(f"{RAW}/{name}.gif", timeout=30) as response: |
|
path.write_bytes(response.read()) |
|
return path |
|
|
|
|
|
def load_gif(name: str) -> tuple[list[Image.Image], list[int]]: |
|
"""Return a GIF's coalesced RGBA frames and their durations in milliseconds. |
|
|
|
Frames are cropped to the union of every frame's opaque bounds — cropping |
|
each frame to its own bounds would cancel out the animation's motion. |
|
""" |
|
with Image.open(fetch(name)) as im: |
|
frames = [f.convert("RGBA") for f in ImageSequence.Iterator(im)] |
|
durations = [f.info.get("duration", 0) for f in ImageSequence.Iterator(im)] |
|
|
|
union: tuple[int, int, int, int] | None = None |
|
for frame in frames: |
|
bbox = frame.getbbox() |
|
if bbox is None: |
|
continue |
|
union = bbox if union is None else ( |
|
min(union[0], bbox[0]), |
|
min(union[1], bbox[1]), |
|
max(union[2], bbox[2]), |
|
max(union[3], bbox[3]), |
|
) |
|
if union: |
|
frames = [f.crop(union) for f in frames] |
|
|
|
durations = [FALLBACK_FRAME_MS if d < MIN_FRAME_MS else min(d, 60_000) for d in durations] |
|
return frames, durations |
|
|
|
|
|
def subsample( |
|
frames: list[Image.Image], durations: list[int], limit: int |
|
) -> tuple[list[Image.Image], list[int]]: |
|
"""Drop frames evenly down to `limit`, folding dropped time into the kept frames.""" |
|
if len(frames) <= limit: |
|
return frames, durations |
|
keep = [round(i * len(frames) / limit) for i in range(limit)] |
|
out_durations = [] |
|
for slot, start in enumerate(keep): |
|
end = keep[slot + 1] if slot + 1 < len(keep) else len(frames) |
|
out_durations.append(max(MIN_FRAME_MS, sum(durations[start:end]))) |
|
return [frames[i] for i in keep], out_durations |
|
|
|
|
|
def main() -> None: |
|
"""Render the spritesheet and manifest into `bufo-motion.codex-pet/`.""" |
|
sheet = Image.new("RGBA", (FRAME_W * COLUMNS, FRAME_H * len(CAST)), (0, 0, 0, 0)) |
|
animations: dict[str, dict[str, object]] = {} |
|
|
|
for row, (state, gif) in enumerate(CAST.items()): |
|
frames, durations = subsample(*load_gif(gif), COLUMNS) |
|
|
|
# Every frame shares one placement, so the motion the animator drew is |
|
# what moves — not the framing. |
|
ref = frames[0] |
|
ratio = min(TARGET_H / ref.height, (FRAME_W - 12) / ref.width) |
|
size = (max(1, round(ref.width * ratio)), max(1, round(ref.height * ratio))) |
|
origin = ((FRAME_W - size[0]) // 2, BASELINE - size[1]) |
|
|
|
for col, frame in enumerate(frames): |
|
cell = Image.new("RGBA", (FRAME_W, FRAME_H), (0, 0, 0, 0)) |
|
cell.paste(frame.resize(size, Image.Resampling.LANCZOS), origin) |
|
sheet.alpha_composite(cell, (col * FRAME_W, row * FRAME_H)) |
|
|
|
animations[state] = {"row": row, "frames": len(frames), "frameDurationsMs": durations} |
|
print(f"row {row} {state:14} {gif:18} {len(frames):2d} frames / {sum(durations)}ms") |
|
|
|
BUNDLE.mkdir(exist_ok=True) |
|
sheet.save(BUNDLE / "spritesheet.webp", quality=93, method=6) |
|
|
|
manifest = { |
|
"id": "bufo-motion", |
|
"displayName": "Bufo Motion", |
|
"description": "Hand-drawn bufo animation from knobiknows/all-the-bufo.", |
|
"spritesheetPath": "spritesheet.webp", |
|
"frame": {"width": FRAME_W, "height": FRAME_H}, |
|
"fps": 8, |
|
"defaultAnimation": "idle", |
|
"animations": animations, |
|
} |
|
(BUNDLE / "pet.json").write_text(json.dumps(manifest, indent=2) + "\n") |
|
print(f"\nwrote {BUNDLE}/ ({sheet.width}x{sheet.height})") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |