| """Timing primitives from classical animation (the A1 layer). |
| |
| These compile a sparse sequence of keyframe-cursor states into a dense 24fps |
| frame stream by interpolating between pinned states with classical-animation rules: |
| eases, stride (on-twos), and moving-hold micro-vibration. |
| |
| This is what makes the cursor's temporal layer load-bearing for animation |
| specifically rather than decoration around a video-generation model. |
| """ |
| from __future__ import annotations |
| from dataclasses import dataclass |
| from typing import Callable, Optional |
| import math |
|
|
|
|
| Curve = Callable[[float], float] |
|
|
|
|
| def linear(t: float) -> float: |
| return t |
|
|
|
|
| def ease_in_out_cubic(t: float) -> float: |
| return 4 * t * t * t if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2 |
|
|
|
|
| def ease_in_quad(t: float) -> float: |
| return t * t |
|
|
|
|
| def ease_out_quad(t: float) -> float: |
| return 1 - (1 - t) ** 2 |
|
|
|
|
| def spring(t: float, tension: float = 0.3) -> float: |
| return 1 - math.cos(t * math.pi * (1 + tension)) * math.exp(-3 * t) |
|
|
|
|
| CURVES: dict[str, Curve] = { |
| "linear": linear, |
| "ease": ease_in_out_cubic, |
| "ease-in": ease_in_quad, |
| "ease-out": ease_out_quad, |
| "spring": lambda t: spring(t), |
| } |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class AnimationSegment: |
| """A compiled keyframe→keyframe segment. Intent, not rendered pixels.""" |
|
|
| start_frame: int |
| end_frame: int |
| curve_name: str |
| stride: int = 2 |
| micro_profile: Optional[str] = None |
|
|
|
|
| def ease_between( |
| start_frame: int, |
| end_frame: int, |
| curve: str = "ease", |
| stride: int = 2, |
| ) -> AnimationSegment: |
| if curve not in CURVES: |
| raise ValueError(f"Unknown curve {curve!r}; choices: {sorted(CURVES)}") |
| if stride < 1: |
| raise ValueError(f"stride must be >= 1; got {stride}") |
| return AnimationSegment(start_frame, end_frame, curve, stride) |
|
|
|
|
| def step_on_stride(start_frame: int, end_frame: int, stride: int = 2) -> list[int]: |
| """Return the per-frame *sample frame index* under stride-N discipline. |
| |
| On-twos: every 2 frames advance; intermediate frame holds the prior sample. |
| """ |
| out = [] |
| for f in range(start_frame, end_frame): |
| rel = f - start_frame |
| sample_rel = (rel // stride) * stride |
| out.append(start_frame + sample_rel) |
| return out |
|
|
|
|
| def hold_with_micro( |
| start_frame: int, duration: int, micro: str = "breath" |
| ) -> AnimationSegment: |
| """A moving-hold: spatial pin + micro-vibration to signal life over long holds.""" |
| return AnimationSegment(start_frame, start_frame + duration, "linear", 1, micro) |
|
|
|
|
| def interp_t(curve_name: str, t: float) -> float: |
| return CURVES[curve_name](max(0.0, min(1.0, t))) |
|
|