Source code for simvx.core.animation.player

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