Files
ai-agent/scripts/avatar_pipeline.py
Gabriel Vidal 11c10ab96f feat(ai-agent): pick up & drop the roaming avatar
The bottom-of-screen agent avatar (Hemp Henry) can now be grabbed and
dragged around. While held it plays a new 'lifted' dangle clip and
follows the pointer; on release it drops under gravity, keeps any throw
momentum, and bounces off the walls and floor with damping before
settling and resuming its wander. A quick press with no drag still
counts as a tap (random emote).

- AgentAvatar.tsx: pointer-capture drag + a gravity/bounce fall
  integrator; the wrapper now spans the full content area so it can be
  lifted, staying pointer-events-none except on the sprite itself.
- New 'lifted' Hemp Henry clip (nanobanana + brain bgremove): pose added
  to gen-avatar-sprites.sh + avatar_pipeline.py (fps 4), strip + manifest
  entry installed.
- install_sheets now merges into the published manifest instead of
  replacing it, so generating a single clip no longer clobbers the rest.
- visibility.ts: new 'Pick up avatar' switch (avatarDrag) so the
  grab/drop behavior can be turned off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 13:26:11 +02:00

147 lines
5.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""Post-process a nanobanana 2x2 sprite grid into an aligned sprite-sheet strip.
Per clip: split the grid into 4 quadrant frames, strip each frame's background
via the brain `bgremove` model (EVOX2), then compose a 4x1 horizontal strip.
Alignment: one uniform scale per clip (largest frame fits the cell), each frame
bottom-anchored on a common baseline so feet stay planted across frames.
Usage:
avatar_pipeline.py <grid.png> <clip-name> <out-dir> [--cell 512] [--keep-bg]
Needs Pillow + requests (run under a venv, see gen-avatar-sprites.sh) and
BRAIN_API_KEY in the environment (gen-avatar-sprites.sh sources the repo .env).
Writes <out-dir>/sheets/<clip>.png, per-frame cutouts under
<out-dir>/cutouts/<clip>/, and updates <out-dir>/manifest.json.
"""
from __future__ import annotations
import argparse
import base64
import io
import json
import os
import sys
import time
from pathlib import Path
import requests
from PIL import Image
BRAIN_URL = os.environ.get("BRAIN_URL", "http://100.93.171.39:8010").rstrip("/")
BRAIN_KEY = os.environ.get("BRAIN_API_KEY", "")
# Playback defaults per clip; anything absent uses DEFAULT_META.
DEFAULT_META = {"frames": 4, "fps": 6, "loop": True}
CLIP_META = {
"celebrate": {"fps": 8},
"walk": {"fps": 8},
"oops": {"fps": 4},
"thinking": {"fps": 3},
"idle": {"fps": 3},
"lifted": {"fps": 4},
}
def bgremove(png_bytes: bytes) -> Image.Image:
"""Send one frame through the brain bgremove model, return RGBA cutout."""
if not BRAIN_KEY:
sys.exit("BRAIN_API_KEY not set")
h = {"X-API-Key": BRAIN_KEY}
r = requests.post(
f"{BRAIN_URL}/api/jobs",
headers={**h, "Content-Type": "application/json"},
json={"model": "bgremove",
"input": {"image_b64": base64.b64encode(png_bytes).decode()}},
timeout=30,
)
r.raise_for_status()
job_id = r.json()["job_id"]
deadline = time.time() + 300
while time.time() < deadline:
s = requests.get(f"{BRAIN_URL}/api/jobs/{job_id}", headers=h, timeout=30).json()
state = s.get("state") or s.get("status")
if state in ("done", "succeeded", "completed"):
break
if state in ("failed", "error", "cancelled"):
sys.exit(f"bgremove job {job_id} failed: {s}")
time.sleep(2)
else:
sys.exit(f"bgremove job {job_id} timed out")
res = requests.get(f"{BRAIN_URL}/api/jobs/{job_id}/result", headers=h, timeout=60)
res.raise_for_status()
return Image.open(io.BytesIO(res.content)).convert("RGBA")
def split_grid(grid: Image.Image) -> list[Image.Image]:
w, h = grid.size
cw, ch = w // 2, h // 2
boxes = [(0, 0), (cw, 0), (0, ch), (cw, ch)] # TL TR BL BR = frames 1..4
return [grid.crop((x, y, x + cw, y + ch)) for x, y in boxes]
def alpha_bbox(img: Image.Image) -> tuple[int, int, int, int]:
bbox = img.getchannel("A").getbbox()
return bbox or (0, 0, img.width, img.height)
def compose_strip(frames: list[Image.Image], cell: int) -> Image.Image:
bboxes = [alpha_bbox(f) for f in frames]
# One scale for the whole clip so inter-frame motion is preserved.
biggest = max(max(x1 - x0, y1 - y0) for x0, y0, x1, y1 in bboxes)
scale = (cell * 0.92) / biggest if biggest else 1.0
baseline = int(cell * 0.97)
strip = Image.new("RGBA", (cell * len(frames), cell), (0, 0, 0, 0))
for i, (frame, (x0, y0, x1, y1)) in enumerate(zip(frames, bboxes)):
cut = frame.crop((x0, y0, x1, y1))
cut = cut.resize((max(1, int(cut.width * scale)),
max(1, int(cut.height * scale))), Image.LANCZOS)
px = i * cell + (cell - cut.width) // 2
py = baseline - cut.height
strip.paste(cut, (px, max(0, py)), cut)
return strip
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("grid")
ap.add_argument("clip")
ap.add_argument("out")
ap.add_argument("--cell", type=int, default=512)
ap.add_argument("--keep-bg", action="store_true",
help="skip bgremove (debugging the crop/align only)")
args = ap.parse_args()
out = Path(args.out)
cutdir = out / "cutouts" / args.clip
cutdir.mkdir(parents=True, exist_ok=True)
(out / "sheets").mkdir(parents=True, exist_ok=True)
grid = Image.open(args.grid).convert("RGBA")
frames = []
for i, quad in enumerate(split_grid(grid), 1):
if args.keep_bg:
cut = quad
else:
buf = io.BytesIO()
quad.save(buf, "PNG")
print(f"[{args.clip}] bgremove frame {i}/4 …", flush=True)
cut = bgremove(buf.getvalue())
cut.save(cutdir / f"{i}.png")
frames.append(cut)
strip = compose_strip(frames, args.cell)
sheet = out / "sheets" / f"{args.clip}.png"
strip.save(sheet)
manifest_path = out / "manifest.json"
manifest = json.loads(manifest_path.read_text()) if manifest_path.exists() else {}
manifest[args.clip] = {**DEFAULT_META, **CLIP_META.get(args.clip, {}),
"cell": args.cell}
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
print(f"[{args.clip}] sheet -> {sheet}")
if __name__ == "__main__":
main()