"""studio CLI — verbs mirror cursor ops. Subcommands: open validate; print cursor state compile -o two-keyframe timing-layer compile export -o 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 / 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())