135 lines
4.1 KiB
Python
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()
|