open-design/skills/hatch-pet/scripts/render_animation_videos.py
Zakaria a46764fb1b
Some checks failed
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
first-commit
2026-05-04 14:58:14 -04:00

135 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Render Codex pet state videos from an atlas using ffmpeg."""
from __future__ import annotations
import argparse
import shutil
import subprocess
import tempfile
from pathlib import Path
from PIL import Image, ImageDraw
CELL_WIDTH = 192
CELL_HEIGHT = 208
STATES = {
"idle": (0, [280, 110, 110, 140, 140, 320]),
"running-right": (1, [120, 120, 120, 120, 120, 120, 120, 220]),
"running-left": (2, [120, 120, 120, 120, 120, 120, 120, 220]),
"waving": (3, [140, 140, 140, 280]),
"jumping": (4, [140, 140, 140, 140, 280]),
"failed": (5, [140, 140, 140, 140, 140, 140, 140, 240]),
"waiting": (6, [150, 150, 150, 150, 150, 260]),
"running": (7, [120, 120, 120, 120, 120, 220]),
"review": (8, [150, 150, 150, 150, 150, 280]),
}
def checker(size: tuple[int, int], square: int = 16) -> Image.Image:
image = Image.new("RGB", size, "#ffffff")
draw = ImageDraw.Draw(image)
for y in range(0, size[1], square):
for x in range(0, size[0], square):
if (x // square + y // square) % 2:
draw.rectangle((x, y, x + square - 1, y + square - 1), fill="#e8e8e8")
return image
def shell_quote_for_concat(path: Path) -> str:
return "'" + str(path).replace("'", "'\\''") + "'"
def render_state(
atlas: Image.Image,
state: str,
row: int,
durations: list[int],
output_dir: Path,
loops: int,
scale: int,
ffmpeg: str,
) -> None:
with tempfile.TemporaryDirectory(prefix=f"codex-pet-{state}-") as temp_raw:
temp = Path(temp_raw)
frame_paths: list[Path] = []
for column in range(len(durations)):
crop = atlas.crop(
(
column * CELL_WIDTH,
row * CELL_HEIGHT,
(column + 1) * CELL_WIDTH,
(row + 1) * CELL_HEIGHT,
)
).convert("RGBA")
bg = checker((CELL_WIDTH, CELL_HEIGHT))
bg.paste(crop, (0, 0), crop)
frame_path = temp / f"{state}-{column:02d}.png"
bg.save(frame_path)
frame_paths.append(frame_path)
concat_path = temp / f"{state}.ffconcat"
lines = ["ffconcat version 1.0"]
sequence: list[tuple[Path, int]] = []
for _ in range(loops):
sequence.extend(zip(frame_paths, durations, strict=True))
for frame_path, duration_ms in sequence:
lines.append(f"file {shell_quote_for_concat(frame_path)}")
lines.append(f"duration {duration_ms / 1000:.3f}")
lines.append(f"file {shell_quote_for_concat(sequence[-1][0])}")
concat_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
output = output_dir / f"{state}.mp4"
command = [
ffmpeg,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_path),
"-vf",
f"scale={CELL_WIDTH * scale}:{CELL_HEIGHT * scale}:flags=lanczos,format=yuv420p",
"-movflags",
"+faststart",
str(output),
]
subprocess.run(command, check=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("atlas")
parser.add_argument("--output-dir", required=True)
parser.add_argument("--loops", type=int, default=4)
parser.add_argument("--scale", type=int, default=2)
parser.add_argument("--ffmpeg", default=shutil.which("ffmpeg") or "ffmpeg")
args = parser.parse_args()
output_dir = Path(args.output_dir).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
with Image.open(Path(args.atlas).expanduser().resolve()) as opened:
atlas = opened.convert("RGBA")
for state, (row, durations) in STATES.items():
render_state(
atlas,
state,
row,
durations,
output_dir,
args.loops,
args.scale,
args.ffmpeg,
)
print(f"wrote videos to {output_dir}")
if __name__ == "__main__":
main()