"""24fps compiler: keyframe cursors + AnimationSegments -> dense 24fps FrameStack. The on-twos discipline (A1) is realized here. For each segment with stride=N, the compiler advances to a new sample every N frames; intermediate frames hold the prior sample. This is what makes the cursor's temporal layer load-bearing for animation specifically — not decoration around a black-box video model. This is real, runnable today on numpy alone. It alpha-blends keyframe artifacts along eased curves; with a backend providing write_motion, the same compile path fills the same FrameStack from model-generated samples instead. """ from __future__ import annotations from typing import Iterable import numpy as np from pixel_cursor import PixelCursor, AnimationSegment, FrameStack, Image, interp_t from pixel_cursor.artifact import _new_framestack def compile_keyframes( keyframe_cursors: Iterable[PixelCursor], segments: Iterable[AnimationSegment], target_fps: int = 24, ) -> FrameStack: kf = list(keyframe_cursors) segs = list(segments) if len(kf) < 2: raise ValueError("Need at least 2 keyframe cursors") if len(segs) != len(kf) - 1: raise ValueError( f"Expected {len(kf) - 1} segments for {len(kf)} keyframes; got {len(segs)}" ) for cur in kf: if not isinstance(cur.artifact, Image): raise TypeError( f"Keyframe cursors must point at Image artifacts; got {type(cur.artifact).__name__}" ) total_frames = segs[-1].end_frame ref_pixels = kf[0].artifact.pixels h, w, c = ref_pixels.shape out = np.zeros((total_frames, h, w, c), dtype=np.uint8) for seg_idx, seg in enumerate(segs): a = kf[seg_idx].artifact.pixels b = kf[seg_idx + 1].artifact.pixels if a.shape != b.shape: raise ValueError( f"Segment {seg_idx}: keyframe shapes differ {a.shape} vs {b.shape}" ) seg_len = seg.end_frame - seg.start_frame for f in range(seg.start_frame, seg.end_frame): rel = f - seg.start_frame sample_rel = (rel // seg.stride) * seg.stride denom = max(1, seg_len - 1) t = sample_rel / denom eased = interp_t(seg.curve_name, t) blended = (1.0 - eased) * a.astype(np.float32) + eased * b.astype(np.float32) out[f] = np.clip(blended, 0, 255).astype(np.uint8) return _new_framestack(out, fps=target_fps, name="compiled-24fps")