"""Keyframe animation tracks and clips."""
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from ..math.types import Quat, Vec2, Vec3
from ._interpolate import _interpolate
from .tween import ease_linear
log = logging.getLogger(__name__)
# ============================================================================
# AnimationEvent
# ============================================================================
[docs]
@dataclass
class AnimationEvent:
"""Event that fires when playback crosses a specific timestamp.
Attributes:
time: Timestamp in seconds when the event fires.
callback: Function to call when the event triggers.
args: Positional arguments passed to callback.
"""
time: float
callback: Callable
args: tuple = ()
# ============================================================================
# Track
# ============================================================================
[docs]
class Track:
"""Property animation track with keyframe interpolation.
Stores (time, value) keyframes and interpolates between them.
Optionally holds AnimationEvents that fire when playback crosses their timestamp.
"""
def __init__(self, property_name: str):
self.property_name = property_name
self.keyframes: list[tuple[float, Any]] = [] # [(time, value), ...]
self.easing = ease_linear
self.events: list[AnimationEvent] = []
[docs]
def add_keyframe(self, time: float, value: Any):
"""Add keyframe at given time."""
self.keyframes.append((time, value))
# Keep sorted by time
self.keyframes.sort(key=lambda kf: kf[0])
[docs]
def add_event(self, time: float, callback: Callable, *args):
"""Add an event that fires when playback crosses the given timestamp.
Args:
time: Timestamp in seconds.
callback: Function to invoke.
*args: Additional arguments forwarded to callback.
"""
self.events.append(AnimationEvent(time, callback, args))
self.events.sort(key=lambda e: e.time)
[docs]
def fire_events(self, prev_time: float, cur_time: float) -> None:
"""Fire events whose timestamps fall in the half-open interval (prev_time, cur_time].
Args:
prev_time: Playback time at previous frame.
cur_time: Playback time at current frame.
"""
for evt in self.events:
if prev_time < evt.time <= cur_time:
try:
evt.callback(*evt.args)
except Exception:
log.exception("Animation event at t=%.3f raised", evt.time)
[docs]
def evaluate(self, time: float) -> Any:
"""Evaluate track at given time."""
if not self.keyframes:
return None
# Before first keyframe
if time <= self.keyframes[0][0]:
return self.keyframes[0][1]
# After last keyframe
if time >= self.keyframes[-1][0]:
return self.keyframes[-1][1]
# Find surrounding keyframes
for i in range(len(self.keyframes) - 1):
t0, v0 = self.keyframes[i]
t1, v1 = self.keyframes[i + 1]
if t0 <= time <= t1:
# Interpolate
duration = t1 - t0
if duration <= 0:
return v1
t = (time - t0) / duration
eased_t = self.easing(t)
return _interpolate(v0, v1, eased_t)
return self.keyframes[-1][1]
# ============================================================================
# AnimationClip
# ============================================================================
[docs]
class AnimationClip:
"""Timeline-based animation with multiple property tracks.
Example:
clip = AnimationClip("jump", duration=1.0)
clip.add_track("position", [
(0.0, Vec3(0, 0, 0)),
(0.5, Vec3(0, 5, 0)),
(1.0, Vec3(0, 0, 0)),
])
clip.add_track("rotation", [
(0.0, 0.0),
(1.0, 360.0),
])
"""
def __init__(self, name: str, duration: float):
self.name = name
self.duration = duration
self.tracks: dict[str, Track] = {}
[docs]
def add_track(self, property_name: str, keyframes: list[tuple[float, Any]], easing=ease_linear):
"""Add property track with keyframes."""
track = Track(property_name)
track.easing = easing
for time, value in keyframes:
track.add_keyframe(time, value)
self.tracks[property_name] = track
[docs]
def evaluate(self, time: float) -> dict[str, Any]:
"""Evaluate all tracks at given time."""
return {prop: track.evaluate(time) for prop, track in self.tracks.items()}
[docs]
def to_dict(self) -> dict:
"""Serialize clip."""
return {
"name": self.name,
"duration": self.duration,
"tracks": {
prop: {"keyframes": [(t, self._serialize_value(v)) for t, v in track.keyframes]}
for prop, track in self.tracks.items()
},
}
@staticmethod
def _serialize_value(value: Any) -> Any:
"""Serialize keyframe value."""
if isinstance(value, (Vec2, Vec3)):
return list(value)
elif isinstance(value, Quat):
return [value.x, value.y, value.z, value.w]
return value
[docs]
@classmethod
def from_dict(cls, data: dict):
"""Deserialize clip."""
clip = cls(data["name"], data["duration"])
for prop, track_data in data.get("tracks", {}).items():
keyframes = []
for t, v in track_data["keyframes"]:
# Type inference from first keyframe
if isinstance(v, list):
if len(v) == 2:
v = Vec2(*v)
elif len(v) == 3:
v = Vec3(*v)
elif len(v) == 4:
v = Quat(v[3], v[0], v[1], v[2]) # w,x,y,z from x,y,z,w storage
keyframes.append((t, v))
clip.add_track(prop, keyframes)
return clip