| """studio CLI — verbs mirror cursor ops. |
| |
| Subcommands: |
| open <image> validate; print cursor state |
| compile <kf0> <kf1> -o <out.npz> two-keyframe timing-layer compile |
| export <frames.npz> -o <out.gif|.mp4|.webm> render FrameStack to disk |
| """ |
| from __future__ import annotations |
| import argparse |
| import sys |
|
|
| import json |
|
|
| from pixel_cursor import open_cursor, ease_between, load_image |
| from . import Studio |
| from .io import save_framestack_npz, load_framestack_npz, export_video |
|
|
|
|
| def cmd_open(args: argparse.Namespace) -> int: |
| img = load_image(args.image) |
| cur = open_cursor(img) |
| print(f"opened: {img}") |
| for k, v in cur.tell().items(): |
| print(f" {k}: {v}") |
| return 0 |
|
|
|
|
| def cmd_compile(args: argparse.Namespace) -> int: |
| kf0 = load_image(args.kf0) |
| kf1 = load_image(args.kf1) |
| cur_a = open_cursor(kf0).at_frame(0) |
| cur_b = open_cursor(kf1).at_frame(args.frames - 1) |
| seg = ease_between(0, args.frames, curve=args.curve, stride=args.stride) |
| stack = Studio().compile_24fps_from_keyframes([cur_a, cur_b], [seg]) |
| save_framestack_npz(stack, args.out) |
| print(f"compiled {stack.num_frames} frames @ {stack.fps}fps -> {args.out}") |
| print(f" curve={args.curve} stride={args.stride} (on-{['ones','twos','threes'][min(args.stride-1, 2)]})") |
| return 0 |
|
|
|
|
| def cmd_export(args: argparse.Namespace) -> int: |
| stack = load_framestack_npz(args.frames) |
| out = export_video(stack, args.out) |
| print(f"exported {stack.num_frames} frames @ {stack.fps}fps -> {out}") |
| return 0 |
|
|
|
|
| def cmd_sheet(args: argparse.Namespace) -> int: |
| from .spritesheet import write_sprite_sheet, sheet_metadata |
|
|
| stack = load_framestack_npz(args.frames) |
| out = write_sprite_sheet(stack, args.out, cols=args.cols) |
| meta = sheet_metadata(stack, cols=args.cols) |
| print(f"sheet -> {out}") |
| print(json.dumps(meta, indent=2)) |
| if args.manifest: |
| manifest_path = args.manifest |
| with open(manifest_path, "w") as f: |
| json.dump(meta, f, indent=2) |
| print(f"manifest -> {manifest_path}") |
| return 0 |
|
|
|
|
| def cmd_motion(args: argparse.Namespace) -> int: |
| from .backends.animatediff import AnimateDiffAdapter, MOTION_LORA_MAP |
| from .backends.hf_space import HFSpaceAdapter |
|
|
| if args.list_presets: |
| print("MotionLoRA presets (shared by `animatediff` and `hf_space` backends):") |
| for name, lora_id in MOTION_LORA_MAP.items(): |
| print(f" {name:24s} -> {lora_id}") |
| return 0 |
|
|
| if args.preset is None or args.image is None: |
| print( |
| "error: --preset and --image are required (or use --list-presets)", |
| file=sys.stderr, |
| ) |
| return 2 |
|
|
| if args.backend == "hf_space": |
| if not args.space: |
| print("error: --space <owner>/<name> is required for --backend hf_space", file=sys.stderr) |
| return 2 |
| adapter = HFSpaceAdapter(space_id=args.space, hf_token=args.hf_token) |
| else: |
| adapter = AnimateDiffAdapter() |
| adapter.register() |
|
|
| img = load_image(args.image) |
| cur = open_cursor(img).bind_motion_exemplar([args.preset], backend=args.backend) |
|
|
| motion_spec = { |
| "num_frames": args.num_frames, |
| "num_inference_steps": args.steps, |
| "guidance_scale": args.guidance, |
| "prompt": args.prompt, |
| "negative_prompt": args.negative_prompt, |
| "seed": args.seed, |
| } |
|
|
| if args.dry_run: |
| diag = adapter.dry_run(cur, motion_spec) |
| print(json.dumps(diag, indent=2, default=str)) |
| return 0 |
|
|
| if args.out is None: |
| print("error: -o/--out is required unless --dry-run is set", file=sys.stderr) |
| return 2 |
|
|
| stack = cur.write_motion(motion_spec, backend=args.backend) |
| save_framestack_npz(stack, args.out) |
| print(f"generated {stack.num_frames} frames @ {stack.fps}fps -> {args.out}") |
| print(f" backend={args.backend} preset={args.preset}") |
| return 0 |
|
|
|
|
| def build_parser() -> argparse.ArgumentParser: |
| p = argparse.ArgumentParser( |
| prog="studio", |
| description="Pixel-cursor animation studio (v0: model-free timing compile)", |
| ) |
| sub = p.add_subparsers(dest="cmd", required=True) |
|
|
| p_open = sub.add_parser("open", help="Load an image; print cursor state") |
| p_open.add_argument("image") |
| p_open.set_defaults(func=cmd_open) |
|
|
| p_compile = sub.add_parser( |
| "compile", |
| help="Compile a two-keyframe segment into a FrameStack via the cursor timing layer", |
| ) |
| p_compile.add_argument("kf0", help="first keyframe image") |
| p_compile.add_argument("kf1", help="second keyframe image") |
| p_compile.add_argument("-o", "--out", required=True, help="output .npz path") |
| p_compile.add_argument("--frames", type=int, default=24, help="total frames (default 24)") |
| p_compile.add_argument( |
| "--curve", |
| default="ease", |
| choices=("linear", "ease", "ease-in", "ease-out", "spring"), |
| ) |
| p_compile.add_argument( |
| "--stride", |
| type=int, |
| default=2, |
| help="on-twos=2 (default), on-ones=1, on-threes=3", |
| ) |
| p_compile.add_argument("--fps", type=int, default=24, help="target fps (default 24)") |
| p_compile.set_defaults(func=cmd_compile) |
|
|
| p_export = sub.add_parser( |
| "export", help="Render a FrameStack .npz to a video file" |
| ) |
| p_export.add_argument("frames", help="input .npz from `studio compile`") |
| p_export.add_argument( |
| "-o", "--out", required=True, help="output .gif | .mp4 | .webm | .mkv | .avi" |
| ) |
| p_export.set_defaults(func=cmd_export) |
|
|
| p_sheet = sub.add_parser( |
| "sheet", |
| help="Pack a FrameStack .npz into a sprite-sheet PNG (Babylon SpriteManager friendly)", |
| ) |
| p_sheet.add_argument("frames", help="input .npz from `studio compile` or `studio motion`") |
| p_sheet.add_argument("-o", "--out", required=True, help="output .png path") |
| p_sheet.add_argument("--cols", type=int, default=4, help="frames per row in the grid (default 4)") |
| p_sheet.add_argument("--manifest", help="optional .json manifest path (cell size, fps, loop ms)") |
| p_sheet.set_defaults(func=cmd_sheet) |
|
|
| p_motion = sub.add_parser( |
| "motion", |
| help="Generate motion on a single image via AnimateDiff MotionLoRA preset", |
| ) |
| p_motion.add_argument( |
| "--list-presets", action="store_true", help="List all 8 verified MotionLoRA presets and exit" |
| ) |
| p_motion.add_argument("--preset", help="preset name (see --list-presets)") |
| p_motion.add_argument("--image", help="source image path") |
| p_motion.add_argument("-o", "--out", help="output .npz path (required unless --dry-run)") |
| p_motion.add_argument( |
| "--dry-run", |
| action="store_true", |
| help="Print what would happen + resource estimate; no model load, no output written", |
| ) |
| p_motion.add_argument("--num-frames", type=int, default=16) |
| p_motion.add_argument("--steps", type=int, default=25, help="num_inference_steps") |
| p_motion.add_argument("--guidance", type=float, default=7.5, help="guidance_scale") |
| p_motion.add_argument("--prompt", default="high quality, detailed") |
| p_motion.add_argument("--negative-prompt", default="bad quality, blurry") |
| p_motion.add_argument("--seed", type=int, default=42) |
| p_motion.add_argument( |
| "--backend", |
| default="animatediff", |
| choices=("animatediff", "hf_space"), |
| help="local diffusers run (animatediff) or remote ZeroGPU Space (hf_space)", |
| ) |
| p_motion.add_argument( |
| "--space", default=None, |
| help="HF Space id (e.g. owner/venture-studio); required when --backend hf_space", |
| ) |
| p_motion.add_argument( |
| "--hf-token", default=None, |
| help="HF token (overrides HF_TOKEN env var and ~/.cache/huggingface/token)", |
| ) |
| p_motion.set_defaults(func=cmd_motion) |
|
|
| return p |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| parser = build_parser() |
| args = parser.parse_args(argv) |
| return args.func(args) |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|