"""Keyframe animation tracks and clips."""
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
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()}