Skip to content

Instantly share code, notes, and snippets.

@phyous
Created August 5, 2026 21:55
Show Gist options
  • Select an option

  • Save phyous/517cc1d33b8920ddea2a615b1abb784f to your computer and use it in GitHub Desktop.

Select an option

Save phyous/517cc1d33b8920ddea2a615b1abb784f to your computer and use it in GitHub Desktop.
Bufo Motion — an animated bufo pet for Codex (.codex-pet bundle), hand-drawn frames from all-the-bufo

Bufo Motion — an animated bufo pet for Codex

A .codex-pet bundle whose every animation row is real hand-drawn animation, lifted frame-for-frame from the GIFs in knobiknows/all-the-bufo with each GIF's authored per-frame timing preserved.

states

Install

Download pet.json and spritesheet.webp into a folder named bufo-motion.codex-pet:

mkdir -p bufo-motion.codex-pet && cd bufo-motion.codex-pet
GIST=https://gist.githubusercontent.com/phyous/517cc1d33b8920ddea2a615b1abb784f/raw
curl -fLO "$GIST/pet.json"
curl -fLO "$GIST/spritesheet.webp"

Then point your Codex-compatible client at the folder. In Orca that's the status bar Pet menu → Choose petImport .codex-pet bundle….

What plays when

The nine rows are Codex's standard set. A host picks the row from agent state; hover and drag override it.

Row Plays when Source GIF
idle nothing running bufo-vibe
running an agent is working bufo-keyboard
waiting an agent is blocked or asking you bufo-is-it-done
review an agent finished bufo-done-check
jumping you hover the pet bufo-yay
running-right you drag the pet right bufo-run-right
running-left you drag the pet left bufo-run
waving greeting bufo-wave
failed something broke bufo-crying

bufo-is-it-done and bufo-done-check are the reason this cast works: both animate a checkbox filling in, so "your agent needs you" and "your agent finished" are readable at a glance without reading any text.

Format

spritesheet.webp is a 3072×1872 grid of 192×208 frames — Codex's native frame size, widened from 8 to 16 columns to fit the longer sequences. Rows are indexed from the top in the order above. Each animation declares its own frame count and per-frame durations in pet.json, so the unused columns at the end of a short row are simply never shown.

Rendering is nearest-neighbour (image-rendering: pixelated) with the frame stepped via background-position, so frame durations drive the animation directly rather than a single sheet-wide fps.

Rebuilding / re-casting

bufo-motion-build.py regenerates the bundle from scratch — it downloads the source GIFs and bakes the sheet. Swapping any row is one line in CAST:

uv run --with pillow python bufo-motion-build.py

There are ~250 animated bufos to choose from, so bufo-hackerman for working or bufo-notice-me-senpai for waiting are both a one-word edit away.

Credit

All bufo art is from knobiknows/all-the-bufo and belongs to its many creators. This bundle only re-times and re-packs their frames.

"""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()
{
"id": "bufo-motion",
"displayName": "Bufo Motion",
"description": "Real hand-drawn animation, lifted frame-for-frame from the repo's GIFs. The checkbox literally ticks when your agent finishes.",
"spritesheetPath": "spritesheet.webp",
"frame": {
"width": 192,
"height": 208
},
"fps": 8,
"defaultAnimation": "idle",
"animations": {
"idle": {
"row": 0,
"frames": 4,
"frameDurationsMs": [
100,
100,
100,
100
]
},
"running-right": {
"row": 1,
"frames": 3,
"frameDurationsMs": [
50,
50,
50
]
},
"running-left": {
"row": 2,
"frames": 3,
"frameDurationsMs": [
50,
50,
50
]
},
"waving": {
"row": 3,
"frames": 4,
"frameDurationsMs": [
40,
40,
40,
40
]
},
"jumping": {
"row": 4,
"frames": 4,
"frameDurationsMs": [
100,
100,
100,
100
]
},
"failed": {
"row": 5,
"frames": 16,
"frameDurationsMs": [
120,
180,
120,
180,
120,
120,
180,
120,
120,
180,
120,
120,
180,
120,
180,
120
]
},
"waiting": {
"row": 6,
"frames": 4,
"frameDurationsMs": [
200,
200,
200,
200
]
},
"running": {
"row": 7,
"frames": 9,
"frameDurationsMs": [
60,
60,
60,
60,
60,
60,
60,
60,
60
]
},
"review": {
"row": 8,
"frames": 4,
"frameDurationsMs": [
200,
200,
200,
200
]
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment