| """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") |
|
|