File size: 8,088 Bytes
1e3ce4c 7faaef2 1e3ce4c 7faaef2 1e3ce4c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | """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())
|