"""I/O helpers: FrameStack <-> .npz round-trip, ndarray -> video file. GIF export uses Pillow (always available). MP4/WebM/MKV export uses imageio when installed (optional extra: `pip install 'venture-studio[video]'`); without it, those extensions raise a clean ImportError with the install instruction. """ from __future__ import annotations from pathlib import Path import numpy as np from PIL import Image as PILImage from pixel_cursor import FrameStack from pixel_cursor.artifact import _new_framestack def save_framestack_npz(stack: FrameStack, path: Path | str) -> Path: out = Path(path) np.savez_compressed( str(out), frames=stack.frames, fps=np.array(stack.fps, dtype=np.int32), name=np.array(stack.name, dtype="U256"), ) return out def load_framestack_npz(path: Path | str) -> FrameStack: data = np.load(str(path), allow_pickle=False) name = str(data["name"]) if "name" in data.files else "" return _new_framestack(data["frames"], fps=int(data["fps"]), name=name) def export_video(stack: FrameStack, out_path: Path | str) -> Path: out = Path(out_path) ext = out.suffix.lower() if ext == ".gif": return _export_gif(stack, out) if ext in (".mp4", ".webm", ".mkv", ".avi"): return _export_imageio(stack, out) raise ValueError( f"Unsupported export extension {ext!r}; supported: .gif .mp4 .webm .mkv .avi" ) def _export_gif(stack: FrameStack, out: Path) -> Path: frames = [PILImage.fromarray(stack.frames[i]) for i in range(stack.num_frames)] duration_ms = max(1, 1000 // max(1, stack.fps)) frames[0].save( out, save_all=True, append_images=frames[1:], duration=duration_ms, loop=0 ) return out def _export_imageio(stack: FrameStack, out: Path) -> Path: try: import imageio.v3 as iio except ImportError as e: raise ImportError( f"Exporting to {out.suffix} requires imageio. Install with: " f"pip install 'venture-studio[video]' (or: pip install imageio imageio-ffmpeg)" ) from e iio.imwrite(str(out), stack.frames, fps=stack.fps) return out