"""AnimationPlayer -- timeline-based animation playback node."""
import logging
from typing import TYPE_CHECKING
import numpy as np
from ..descriptors import Property
from ..node import Node
from ..signals import Signal
from ._interpolate import _blend_values
from .track import AnimationClip
if TYPE_CHECKING:
from .skeletal import SkeletalAnimationClip
log = logging.getLogger(__name__)
class _SkeletonProperty(Property):
"""The declared Property behind :attr:`AnimationPlayer.skeleton`.
Bone tracks are produced by glTF import, which yields 3D rigs, so the only
pose target the skeletal path can drive is a :class:`~simvx.core.skeleton.Skeleton`.
A ``Skeleton2D`` exposes ``bones`` and ``bone_count`` as well, which is exactly
the pair the bind-pose lookup guards on, so it passes every check and then
fails several frames later inside a private method. Refusing it here puts the
error on the line that wrote it.
This is the *validating* Property subclass: it keeps the base class's storage
and its whole ``__get__``, and narrows only what may be assigned. It is not
the *derived* form (``_DirectionProperty`` in
:mod:`simvx.core.nodes_3d.lights`), which owns no storage and computes both
accessors. Copying one where the other is wanted gives either a descriptor
that writes to a slot nothing reads, or a stored value that shadows the
computation it was meant to replace.
"""
__slots__ = ()
def __set__(self, obj, value):
if value is not None:
from ..skeleton import Skeleton
if not isinstance(value, Skeleton):
raise TypeError(
f"{type(obj).__name__}.{self.name} must be a Skeleton node or None, "
f"got {type(value).__name__}. Skeletal clips drive a 3D bone hierarchy; "
"animate a Skeleton2D with property tracks on its Bone2D children "
"(`rotation`, `bone_angle`) instead."
)
super().__set__(obj, value)
[docs]
class AnimationPlayer(Node):
"""Plays timeline-based animation clips on a target node.
As a Node subclass, it participates in the scene tree and gets
``on_update(dt)`` called automatically. By default it animates its parent.
Supports crossfading between clips, firing track events, and skeletal
animation via ``SkeletalAnimationClip`` + ``Skeleton``.
Properties:
target: The node whose properties are animated. Defaults to
``parent`` if not set explicitly.
skeleton: Optional ``Skeleton`` node for bone-track playback. Only a 3D
``Skeleton`` (or ``None``) is accepted; see :class:`_SkeletonProperty`.
playing: Whether playback is active.
speed_scale: Playback speed multiplier (1.0 = normal).
loop: Whether the current clip should loop when finished.
Attributes:
clips: Dictionary of registered ``AnimationClip`` objects keyed by name.
skeletal_clips: Dictionary of ``SkeletalAnimationClip`` objects keyed by name.
current_clip: Name of the currently playing clip, or ``None``.
current_time: Playhead position within the current clip, in seconds.
Playback state rather than authoring state, so it is transient: a
scene saved mid-clip reloads at the start of that clip, not at
whatever second the save happened to catch. Move it with
:meth:`seek`.
animation_finished: Signal emitted when a non-looping clip ends.
Example::
player = AnimationPlayer()
player.add_clip(jump_clip)
player.add_clip(run_clip)
player.play("jump")
player.crossfade("run", duration=0.3)
# Skeletal animation:
player.skeleton = skeleton_node
player.add_clip(walk_skeletal_clip)
player.play("walk", loop=True)
"""
target = Property(None, hint="Node whose properties are animated (defaults to the parent)")
skeleton = _SkeletonProperty(None, hint="Skeleton driven by bone tracks (defaults to a Skeleton parent)")
playing = Property(False, hint="Whether playback is active")
speed_scale = Property(1.0, hint="Playback speed multiplier (1.0 = normal)")
loop = Property(False, hint="Whether the current clip restarts when it reaches its end")
#: Playback state, which no save carries. The playhead is where the clip
#: has got to, not something an author sets in the inspector, and a
#: declared Property would put the second it was saved at into the file.
__transient__ = frozenset({"current_time"})
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.current_time = 0.0
self.clips: dict[str, AnimationClip] = {}
self.skeletal_clips: dict[str, SkeletalAnimationClip] = {}
self.current_clip: str | None = None
# Crossfade state. ``_crossfade_from_advances`` records whether the
# outgoing clip was still running when the blend started: one that
# had already finished holds its final pose for the blend instead of
# being evaluated past its own end.
self._crossfade_from: str | None = None
self._crossfade_from_time: float = 0.0
self._crossfade_from_advances: bool = True
self._crossfade_duration: float = 0.0
self._crossfade_elapsed: float = 0.0
self._crossfading: bool = False
# (clip name, track name) pairs already reported as unresolvable on the
# target, so each one is logged once instead of on every frame.
self._unresolved_tracks: set[tuple[str, str]] = set()
# Signals
self.animation_finished = Signal()
def _resolve_target(self):
"""Resolve target: use explicit target, else fall back to parent."""
return self.target if self.target is not None else self.parent
def _resolve_skeleton(self):
"""Resolve skeleton: explicit, else try parent."""
if self.skeleton is not None:
return self.skeleton
from ..skeleton import Skeleton
if isinstance(self.parent, Skeleton):
return self.parent
return None
[docs]
def add_clip(self, clip: AnimationClip | SkeletalAnimationClip):
"""Register an animation clip (property-based or skeletal)."""
from .skeletal import SkeletalAnimationClip as _SAC
if isinstance(clip, _SAC):
self.skeletal_clips[clip.name] = clip
else:
self.clips[clip.name] = clip
def _has_clip(self, name: str) -> bool:
"""Check if a clip (property or skeletal) is registered under *name*."""
return name in self.clips or name in self.skeletal_clips
def _clip_duration(self, name: str) -> float:
"""Return duration of a named clip (property or skeletal)."""
if name in self.clips:
return self.clips[name].duration
if name in self.skeletal_clips:
return self.skeletal_clips[name].duration
return 0.0
[docs]
def play(self, clip_name: str, loop: bool = False):
"""Play animation clip, cancelling any active crossfade."""
if not self._has_clip(clip_name):
return
self.current_clip = clip_name
self.current_time = 0.0
self.playing = True
self.loop = loop
self._crossfading = False
def _holds_final_pose(self) -> bool:
"""Whether the current clip has run to its end with that end pose still applied.
A non-looping clip stops at ``current_time == duration`` and leaves
the pose it evaluated there on the target, so it is still a usable
blend source even though ``playing`` is ``False``.
"""
return (
not self.playing
and self.current_clip is not None
and self.current_time >= self._clip_duration(self.current_clip)
)
[docs]
def crossfade(self, clip_name: str, duration: float = 0.3):
"""Blend from the current clip to a new clip over *duration* seconds.
A clip that is still playing keeps playing as it fades out. A
non-looping clip that has already run to its end holds its final
pose for the length of the blend instead of the player snapping
straight onto the incoming clip, so crossfading out of a one-shot
that has just finished blends the way an author expects.
Falls back to ``play()`` when there is nothing to blend from: no
clip has been played, playback was stopped or paused part-way
through, or *clip_name* is not registered.
"""
if not self._has_clip(clip_name) or not self.current_clip or not (self.playing or self._holds_final_pose()):
self.play(clip_name)
return
self._crossfade_from = self.current_clip
self._crossfade_from_time = self.current_time
self._crossfade_from_advances = self.playing
self._crossfade_duration = max(duration, 1e-6)
self._crossfade_elapsed = 0.0
self._crossfading = True
self.current_clip = clip_name
self.current_time = 0.0
self.playing = True
[docs]
def stop(self):
"""Stop playback and reset to the start of the current clip.
``playing`` becomes ``False``, the playhead resets to ``current_time = 0``,
and any crossfade in progress is cancelled.
"""
self.playing = False
self.current_time = 0.0
self._crossfading = False
[docs]
def pause(self):
"""Pause playback, preserving the playhead.
``playing`` becomes ``False`` but ``current_time`` is left untouched;
``resume()`` continues from where playback left off.
"""
self.playing = False
[docs]
def resume(self):
"""Resume playback from the current playhead position."""
self.playing = True
[docs]
def seek(self, time: float):
"""Jump to time in current clip."""
self.current_time = time
[docs]
def on_update(self, dt: float):
"""Advance animation playback each frame (called by SceneTree)."""
if not self.playing or not self.current_clip:
return
clip_name = self.current_clip
is_skeletal = clip_name in self.skeletal_clips
duration = self._clip_duration(clip_name)
prev_time = self.current_time
self.current_time += dt * self.speed_scale
# Crossfade in progress: blend outgoing -> incoming. Skeletal and
# property crossfades take separate (TRS vs property) blend paths.
if self._crossfading and self._crossfade_from:
from_is_skeletal = self._crossfade_from in self.skeletal_clips
if is_skeletal or from_is_skeletal:
self._apply_skeletal_crossfade(dt, clip_name, from_is_skeletal, is_skeletal)
return
target = self._resolve_target()
if not target:
return
clip = self.clips[clip_name]
self._apply_crossfade(dt, clip, prev_time, target)
return
# Loop or finish. Track whether playback wrapped this frame so events
# in the (prev_time, duration] tail segment fire before the wrap.
wrapped = False
if self.current_time >= duration:
if self.loop:
if duration > 0:
wrapped = True
self.current_time = self.current_time % duration
else:
self.current_time = 0.0
else:
self.current_time = duration
self.playing = False
self.animation_finished()
if is_skeletal:
self._apply_skeletal_clip(self.skeletal_clips[clip_name])
else:
target = self._resolve_target()
if not target:
return
clip = self.clips[clip_name]
if wrapped:
# Fire events in two segments around the wrap so events in
# (prev_time, duration] are not silently dropped when
# self.current_time wraps back to a small value.
self._fire_track_events_in_range(clip, prev_time, duration)
self._fire_track_events_in_range(clip, 0.0, self.current_time)
else:
self._fire_track_events(clip, prev_time)
self._apply_clip_values(clip, target)
def _apply_crossfade(self, dt: float, clip: AnimationClip, prev_time: float, target):
"""Blend between outgoing and incoming clips during crossfade."""
self._crossfade_elapsed += dt
blend_t = min(1.0, self._crossfade_elapsed / self._crossfade_duration)
from_clip = self.clips[self._crossfade_from]
if self._crossfade_from_advances:
self._crossfade_from_time += dt * self.speed_scale
from_values = from_clip.evaluate(self._crossfade_from_time)
to_values = clip.evaluate(self.current_time)
for prop in set(from_values.keys()) | set(to_values.keys()):
blended = _blend_values(from_values.get(prop), to_values.get(prop), blend_t)
if blended is not None and hasattr(target, prop):
setattr(target, prop, blended)
self._fire_track_events(clip, prev_time)
if blend_t >= 1.0:
self._crossfading = False
self._crossfade_from = None
def _apply_skeletal_crossfade(self, dt: float, clip_name: str, from_is_skeletal: bool, to_is_skeletal: bool):
"""Blend per-bone TRS poses between the outgoing and incoming clips.
At least one side is skeletal. Bone poses are sampled in TRS space from
both clips, blended (SLERP rotation, LERP position/scale) by ``blend_t``,
and written to the skeleton. On completion the player settles exactly
onto the incoming clip.
"""
from .skeletal import blend_trs
skel = self._resolve_skeleton()
if skel is None:
return
self._crossfade_elapsed += dt
blend_t = min(1.0, self._crossfade_elapsed / self._crossfade_duration)
if self._crossfade_from_advances:
self._crossfade_from_time += dt * self.speed_scale
from_trs = self._sample_pose_trs(self._crossfade_from, from_is_skeletal, self._crossfade_from_time)
to_trs = self._sample_pose_trs(clip_name, to_is_skeletal, self.current_time)
identity = (
np.zeros(3, dtype=np.float32),
np.array([0, 0, 0, 1], dtype=np.float32),
np.ones(3, dtype=np.float32),
)
for bone_idx in set(from_trs.keys()) | set(to_trs.keys()):
a = from_trs.get(bone_idx, identity)
b = to_trs.get(bone_idx, identity)
skel.set_bone_pose(bone_idx, blend_trs(a, b, blend_t))
skel.compute_pose()
if blend_t >= 1.0:
self._crossfading = False
self._crossfade_from = None
def _sample_pose_trs(self, clip_name: str, is_skeletal: bool, time: float):
"""Return {bone_index: (t, r, s)} for a clip at *time*.
Property clips contribute no bone poses (empty dict) so a skeletal
crossfade against a property clip blends the skeletal side toward rest.
"""
if is_skeletal:
clip = self.skeletal_clips[clip_name]
skel = self._resolve_skeleton()
rest = self._bind_rest_poses(skel, clip) if skel is not None else None
return clip.evaluate_trs(time, rest)
return {}
def _fire_track_events(self, clip: AnimationClip, prev_time: float):
"""Fire any events on clip tracks between prev_time and current_time."""
for track in clip.tracks.values():
if track.events:
track.fire_events(prev_time, self.current_time)
def _fire_track_events_in_range(self, clip: AnimationClip, prev_time: float, cur_time: float):
"""Fire events on clip tracks in the half-open interval (prev_time, cur_time].
Used for wrap-aware looping where the playback interval spans a loop
boundary and must be split into two segments.
"""
for track in clip.tracks.values():
if track.events:
track.fire_events(prev_time, cur_time)
def _apply_clip_values(self, clip: AnimationClip, target):
"""Evaluate clip at current_time and set properties on target.
A track naming a property the target does not have is skipped rather
than raising, because the same clip is legitimately shared across
targets of different types. Such a track is also the usual cause of an
animation that appears to do nothing at all, so the first skip of each
(clip, track) pair is logged; later frames stay quiet.
"""
values = clip.evaluate(self.current_time)
for prop, value in values.items():
if hasattr(target, prop):
setattr(target, prop, value)
elif (clip.name, prop) not in self._unresolved_tracks:
self._unresolved_tracks.add((clip.name, prop))
log.warning(
"Animation clip %r: track %r skipped, target of type %s has no such property",
clip.name,
prop,
type(target).__name__,
)
def _apply_skeletal_clip(self, clip: SkeletalAnimationClip):
"""Evaluate a skeletal clip and push bone transforms to the skeleton."""
skel = self._resolve_skeleton()
if skel is None:
return
bone_transforms = clip.evaluate(self.current_time, self._bind_rest_poses(skel, clip))
for bone_idx, transform in bone_transforms.items():
skel.set_bone_pose(bone_idx, transform)
skel.compute_pose()
@staticmethod
def _bind_rest_poses(skel, clip) -> dict:
"""Each animated bone's bind ``local_transform`` decomposed to rest ``(t, r, s)``.
Passed to the clip evaluator so sparse glTF channels (a bone that animates
only rotation, say) keep their bind translation/scale instead of snapping
to zero, which would collapse the rig.
"""
from .skeletal import decompose_trs
bones = skel.bones
if not bones:
return {}
rest = {}
for track in clip.bone_tracks:
bi = track.bone_index
if 0 <= bi < skel.bone_count:
rest[bi] = decompose_trs(bones[bi].local_transform)
return rest