#!/usr/bin/env python3 """Pack rendered frames into a character folder (CHARACTER_SPEC.md). Takes a renders directory laid out as renders///*.png and assembles characters// with a data.json template per move (timing fields to be tuned by hand, hurtboxes to be filled by autohitbox.py). Usage: python3 pipeline/pack_character.py renders/hero --name HERO --color '#cc4488' """ import argparse import json import shutil from pathlib import Path REPO = Path(__file__).resolve().parent.parent DATA_TEMPLATE = dict(total=30, loop=False, startup=10, active=[11, 14], damage=50, hitstun=16, blockstun=10, pushback=10, hurtboxes={"default": [[-25, -300, 50, 300]]}, hitboxes={}) LOOPED = {"idle", "walk_f", "walk_b"} def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("renders_dir", help="renders/ containing /*.png") ap.add_argument("--name", default=None) ap.add_argument("--color", default="#cccccc") ap.add_argument("--health", type=int, default=1000) args = ap.parse_args() src = Path(args.renders_dir) cid = src.name dst = REPO / "characters" / cid moves = [] for move_dir in sorted(p for p in src.iterdir() if p.is_dir()): frames = sorted(move_dir.glob("*.png")) + sorted(move_dir.glob("*.webp")) if not frames: continue # "@" renders are wardrobe variants: frames go to # moves//frames@/ and no data.json is touched if "@" in move_dir.name: move, state = move_dir.name.split("@", 1) vout = dst / "moves" / move / ("frames@" + state) if vout.exists(): shutil.rmtree(vout) vout.mkdir(parents=True, exist_ok=True) for i, f in enumerate(frames): shutil.copy(f, vout / f"{i:04d}{f.suffix}") moves.append(move_dir.name) continue out = dst / "moves" / move_dir.name / "frames" out.mkdir(parents=True, exist_ok=True) for i, f in enumerate(frames): shutil.copy(f, out / f"{i:04d}{f.suffix}") rm_path = move_dir / "root_motion.json" root_frames = json.loads(rm_path.read_text()) if rm_path.exists() else None data_path = out.parent / "data.json" if not data_path.exists(): # never clobber hand-tuned frame data data = dict(name=move_dir.name, **DATA_TEMPLATE) data["loop"] = move_dir.name in LOOPED if root_frames: data["root_motion_frames"] = root_frames data_path.write_text(json.dumps(data, indent=2)) elif root_frames is not None: # refresh clip-derived motion on repack data = json.loads(data_path.read_text()) data["root_motion_frames"] = root_frames data_path.write_text(json.dumps(data, indent=2)) moves.append(move_dir.name) manifest_path = dst / "manifest.json" if not manifest_path.exists(): manifest_path.write_text(json.dumps({ "id": cid, "name": (args.name or cid).upper(), "health": args.health, "walk_speed": 3.2, "back_speed": 2.6, "jump_velocity": -13.5, "color": args.color, "normals": {"P": "jab", "K": "kick"}, "command_moves": [], }, indent=2)) print(f"packed characters/{cid}: {len(moves)} moves -> {moves}") print("next: pipeline/autohitbox.py for hurtboxes, then tune data.json timings") if __name__ == "__main__": main()