Skip to content

Animation

keyed.animation

Animation related classes/functions.

AnimationType

Bases: Enum

Specifies the mathematical operation used to combine the original and animated values.

Source code in src/keyed/animation.py
class AnimationType(Enum):
    """Specifies the mathematical operation used to combine the original and animated values."""

    MULTIPLY = auto()
    """Multiplies the original value by the animated value."""
    ABSOLUTE = auto()
    """Replaces the original value with the animated value."""
    ADD = auto()
    """Adds the animated value to the original value."""

MULTIPLY class-attribute instance-attribute

MULTIPLY = auto()

Multiplies the original value by the animated value.

ABSOLUTE class-attribute instance-attribute

ABSOLUTE = auto()

Replaces the original value with the animated value.

ADD class-attribute instance-attribute

ADD = auto()

Adds the animated value to the original value.

Animation

Bases: Generic[T]

Define an animation.

Animations vary a parameter over time.

Generally, Animations become active at start_frame and smoothly change according to the easing function until terminating to a final value at end_frame. The animation will remain active (i.e., the parameter will not suddenly jump back to it's pre-animation state), but will cease varying.

Parameters:

Name Type Description Default
start int

Frame at which the animation will become active.

required
end int

Frame at which the animation will stop varying.

required
start_value HasValue[T]

Value at which the animation will start.

required
end_value HasValue[T]

Value at which the animation will end.

required
ease EasingFunctionT

The rate in which the value will change throughout the animation.

linear_in_out
animation_type AnimationType

How the animation value will affect the original value.

ABSOLUTE

Raises:

Type Description
ValueError

When start_frame > end_frame

Source code in src/keyed/animation.py
class Animation(Generic[T]):
    """Define an animation.

    Animations vary a parameter over time.

    Generally, Animations become active at ``start_frame`` and smoothly change
    according to the ``easing`` function until terminating to a final value at
    ``end_frame``. The animation will remain active (i.e., the parameter will
    not suddenly jump back to it's pre-animation state), but will cease varying.

    Args:
        start: Frame at which the animation will become active.
        end: Frame at which the animation will stop varying.
        start_value: Value at which the animation will start.
        end_value: Value at which the animation will end.
        ease: The rate in which the value will change throughout the animation.
        animation_type: How the animation value will affect the original value.

    Raises:
        ValueError: When ``start_frame > end_frame``
    """

    def __init__(
        self,
        start: int,
        end: int,
        start_value: HasValue[T],
        end_value: HasValue[T],
        ease: EasingFunctionT = linear_in_out,
        animation_type: AnimationType = AnimationType.ABSOLUTE,
    ) -> None:
        if start > end:
            raise ValueError("Ending frame must be after starting frame.")
        if not hasattr(self, "start_frame"):
            self.start_frame = start
        if not hasattr(self, "end_frame"):
            self.end_frame = end
        self.start_value = start_value
        self.end_value = end_value
        self.ease = ease
        self.animation_type = animation_type

    def _bind(self, value: HasValue[A], frame: ReactiveValue[int]) -> Computed[A | T]:
        ease = easing_function(start=self.start_frame, end=self.end_frame, ease=self.ease, frame=frame)

        @computed
        def f(value: Any, frame: int, ease: float, start: T, end: T) -> Any:
            eased_value = end * ease + start * (1 - ease)  # pyright: ignore[reportOperatorIssue] # noqa: E501

            match self.animation_type:
                case AnimationType.ABSOLUTE:
                    pass
                case AnimationType.ADD:
                    eased_value = value + eased_value
                case AnimationType.MULTIPLY:
                    eased_value = value * eased_value
                case _:
                    raise ValueError("Undefined AnimationType")

            return value if frame < self.start_frame else eased_value

        return f(value, frame, ease, self.start_value, self.end_value)

    def __call__(self, value: HasValue[A], frame: ReactiveValue[int] | None = None) -> Computed[A | T]:
        """Bind the animation to an input value and frame.

        If omitted, ``frame`` is taken from the active Scene.
        """
        return self._bind(value, _resolve_animation_frame(frame))

    def __len__(self) -> int:
        """Return number of frames in the animation."""
        return self.end_frame - self.start_frame + 1

Loop

Bases: Animation[T], Generic[T]

Loop an animation.

Parameters:

Name Type Description Default
animation Animation[T]

The animation to loop.

required
n int

Number of times to loop the animation.

1
Source code in src/keyed/animation.py
class Loop(Animation[T], Generic[T]):
    """Loop an animation.

    Args:
        animation: The animation to loop.
        n: Number of times to loop the animation.
    """

    def __init__(self, animation: Animation[T], n: int = 1):
        self.animation = animation
        self.n = n
        super().__init__(self.start_frame, self.end_frame, animation.start_value, animation.end_value)

    @property
    def start_frame(self) -> int:  # type: ignore[override]
        """Frame at which the animation will become active."""
        return self.animation.start_frame

    @property
    def end_frame(self) -> int:  # type: ignore[override]
        """Frame at which the animation will stop varying."""
        return self.animation.start_frame + len(self.animation) * self.n

    def __call__(self, value: HasValue[A], frame: ReactiveValue[int] | None = None) -> Computed[A | T]:
        """Apply the animation to the current value at the current frame.

        Args:
            frame: The frame at which the animation is applied.
            value: The initial value.

        Returns:
            The value after the animation.
        """
        resolved_frame = _resolve_animation_frame(frame)
        effective_frame = self.animation.start_frame + (resolved_frame - self.animation.start_frame) % len(
            self.animation
        )
        active_anim = self.animation._bind(value, effective_frame)
        post_anim = self.animation._bind(value, Signal(self.animation.end_frame))

        @computed
        def f(frame: int, value: Any, active_anim: Any, post_anim: Any) -> Any:
            if frame < self.start_frame:
                return value
            elif frame < self.end_frame:
                return active_anim
            else:
                return post_anim

        return f(resolved_frame, value, active_anim, post_anim)

    def __repr__(self) -> str:
        return f"Loop(animation={self.animation}, n={self.n})"

start_frame property

start_frame

Frame at which the animation will become active.

end_frame property

end_frame

Frame at which the animation will stop varying.

PingPong

Bases: Animation[T], Generic[T]

Play an animation forward, then backwards n times.

Parameters:

Name Type Description Default
animation Animation[T]

The animation to ping-pong.

required
n int

Number of full back-and-forth cycles

1
Source code in src/keyed/animation.py
class PingPong(Animation[T], Generic[T]):
    """Play an animation forward, then backwards n times.

    Args:
        animation: The animation to ping-pong.
        n: Number of full back-and-forth cycles
    """

    def __init__(self, animation: Animation[T], n: int = 1):
        self.animation = animation
        self.n = n
        super().__init__(self.start_frame, self.end_frame, animation.start_value, animation.end_value)

    @property
    def start_frame(self) -> int:  # type: ignore[override]
        """Returns the frame at which the animation begins."""
        return self.animation.start_frame

    @property
    def end_frame(self) -> int:  # type: ignore[override]
        """Returns the frame at which the animation stops varying.

        Notes:
            Each cycle consists of going forward and coming back.
        """
        return self.animation.start_frame + self.cycle_len * self.n

    @property
    def cycle_len(self) -> int:
        """Returns the number of frames in one cycle."""
        return 2 * (len(self.animation) - 1)

    def __call__(self, value: HasValue[A], frame: ReactiveValue[int] | None = None) -> Computed[A | T]:
        """Apply the animation to the current value at the current frame.

        Args:
            frame: The frame at which the animation is applied.
            value: The initial value.

        Returns:
            The value after the animation.
        """
        resolved_frame = _resolve_animation_frame(frame)

        # Calculate effective frame based on whether we're in the forward or backward cycle
        @computed
        def effective_frame_(frame: int) -> int:
            frame_in_cycle = (frame - self.start_frame) % self.cycle_len
            return (
                self.animation.start_frame + frame_in_cycle
                if frame_in_cycle < len(self.animation)
                else self.animation.end_frame - (frame_in_cycle - len(self.animation) + 1)
            )

        effective_frame = effective_frame_(resolved_frame)
        anim = self.animation._bind(value, effective_frame)

        @computed
        def f(frame: int, value: Any) -> Any:
            return value if frame < self.start_frame or frame > self.end_frame else anim.value

        return f(resolved_frame, value)

    def __repr__(self) -> str:
        return f"PingPong(animation={self.animation}, n={self.n})"

start_frame property

start_frame

Returns the frame at which the animation begins.

end_frame property

end_frame

Returns the frame at which the animation stops varying.

Notes

Each cycle consists of going forward and coming back.

cycle_len property

cycle_len

Returns the number of frames in one cycle.

stagger

stagger(start_value=0, end_value=1, ease=linear_in_out, animation_type=ABSOLUTE)

Partially-initialize an animation for use with Group.write_on.

This will set the animations values, ease, and type without setting its start/end frames.

Parameters:

Name Type Description Default
start_value float

Value at which the animation will start.

0
end_value float

Value at which the animation will end.

1
ease EasingFunctionT

The rate in which the value will change throughout the animation.

linear_in_out
animation_type AnimationType

How the animation value will affect the original value.

ABSOLUTE

Returns:

Type Description
partial[Animation]

Partially initialized animation.

Source code in src/keyed/animation.py
def stagger(
    start_value: float = 0,
    end_value: float = 1,
    ease: EasingFunctionT = linear_in_out,
    animation_type: AnimationType = AnimationType.ABSOLUTE,
) -> partial[Animation]:
    """Partially-initialize an animation for use with [Group.write_on][keyed.group.Group.write_on].

    This will set the animations values, ease, and type without setting its start/end frames.

    Args:
        start_value: Value at which the animation will start.
        end_value: Value at which the animation will end.
        ease: The rate in which the value will change throughout the animation.
        animation_type: How the animation value will affect the original value.

    Returns:
        Partially initialized animation.
    """
    return partial(
        Animation,
        start_value=start_value,
        end_value=end_value,
        ease=ease,
        animation_type=animation_type,
    )

step

step(value, frame=ALWAYS, animation_type=ABSOLUTE)

Return an animation that applies a step function to the Variable at a particular frame.

Parameters:

Name Type Description Default
value HasValue[T]

The value to step to.

required
frame int

The frame at which the step will be applied.

ALWAYS
animation_type AnimationType

See :class:AnimationType.

ABSOLUTE

Returns:

Type Description
Animation[T]

An animation that applies a step function to the Variable at a particular frame.

Source code in src/keyed/animation.py
def step(
    value: HasValue[T], frame: int = ALWAYS, animation_type: AnimationType = AnimationType.ABSOLUTE
) -> Animation[T]:
    """Return an animation that applies a step function to the Variable at a particular frame.

    Args:
        value: The value to step to.
        frame: The frame at which the step will be applied.
        animation_type: See :class:`AnimationType`.

    Returns:
        An animation that applies a step function to the Variable at a particular frame.
    """
    # Can this be simpler? Something like...
    # def step_builder(initial_value: HasValue[A], frame_rx: ReactiveValue[int]) -> Computed[A|T]:
    #     return (frame_rx >= frame).where(value, initial_value)

    # return step_builder  # Callable[[HasValue[A], ReactiveValue[int]], Computed[A|T]]
    return Animation(
        start=frame,
        end=frame,
        start_value=value,
        end_value=value,
        animation_type=animation_type,
    )

Builders

keyed.builders

Builder APIs for animated values on a timeline.

Cues, Flow, and Keys all compile to the same underlying Animation chain. They differ only in the timing vocabulary they expose:

  • Cues places transitions by absolute start frame.
  • Flow describes a forward-only relative sequence.
  • Keys places absolute points where values arrive.

Cues

Bases: Generic[T]

Build an animation from absolute transition cues.

Each at call names the frame where a transition starts, its target value, and how long it should run. Between cues, the value holds at the previous cue's target.

Use this builder when your timing is already expressed in absolute frames, or when you want to insert transitions out of order and let build sort them chronologically.

Parameters:

Name Type Description Default
initial HasValue[T]

The value before any cues are reached.

required

Examples:

from keyed import Color, easing
from keyed.builders import Cues

black = Color(0, 0, 0)
yellow = Color(0.75, 0.75, 0)

x_color = (
    Cues(black)
    .at(120, yellow, over=12, ease=easing.cubic_in_out)
    .at(156, black, over=12)
    .build()
)
Source code in src/keyed/builders.py
class Cues(Generic[T]):
    """Build an animation from absolute transition cues.

    Each [`at`][keyed.builders.Cues.at] call names the frame where a transition
    starts, its target value, and how long it should run. Between cues, the
    value holds at the previous cue's target.

    Use this builder when your timing is already expressed in absolute frames,
    or when you want to insert transitions out of order and let
    [`build`][keyed.builders.Cues.build] sort them chronologically.

    Args:
        initial: The value before any cues are reached.

    Examples:
        ```python
        from keyed import Color, easing
        from keyed.builders import Cues

        black = Color(0, 0, 0)
        yellow = Color(0.75, 0.75, 0)

        x_color = (
            Cues(black)
            .at(120, yellow, over=12, ease=easing.cubic_in_out)
            .at(156, black, over=12)
            .build()
        )
        ```
    """

    def __init__(self, initial: HasValue[T]) -> None:
        self._initial = initial
        self._cues: list[_Cue] = []

    def at(
        self,
        frame: int,
        to: HasValue[T],
        over: int = 0,
        ease: EasingFunctionT = linear_in_out,
        animation_type: AnimationType = AnimationType.ABSOLUTE,
    ) -> Cues[T]:
        """Add a cue: start transitioning to ``to`` at frame ``frame`` over ``over`` frames.

        Args:
            frame: Frame at which the transition begins.
            to: Target value.
            over: Duration of the transition in frames. ``0`` means an instant snap.
            ease: Easing function.
            animation_type: How to combine with the base value.
        """
        self._cues.append(_Cue(at=frame, to=to, over=over, ease=ease, animation_type=animation_type))
        return self

    def snap(
        self,
        frame: int,
        to: HasValue[T],
        animation_type: AnimationType = AnimationType.ABSOLUTE,
    ) -> Cues[T]:
        """Add an instant snap to ``to`` at frame ``frame``.

        Shorthand for ``.at(frame, to, over=0)``.
        """
        return self.at(frame=frame, to=to, over=0, animation_type=animation_type)

    def build(self, frame: ReactiveValue[int] | None = None) -> ReactiveValue[T]:
        """Build and return the reactive animation value.

        Args:
            frame: Frame signal to evaluate against. Defaults to the active Scene's frame.
        """
        cues = sorted(self._cues, key=lambda k: k.at)
        result: HasValue[T] = self._initial
        prev_value: HasValue[T] = self._initial

        for cue in cues:
            anim = Animation(
                start=cue.at,
                end=cue.at + cue.over,
                start_value=prev_value,
                end_value=cue.to,
                ease=cue.ease,
                animation_type=cue.animation_type,
            )
            result = anim(result, frame)
            prev_value = cue.to

        return as_rx(result)

at

at(frame, to, over=0, ease=linear_in_out, animation_type=ABSOLUTE)

Add a cue: start transitioning to to at frame frame over over frames.

Parameters:

Name Type Description Default
frame int

Frame at which the transition begins.

required
to HasValue[T]

Target value.

required
over int

Duration of the transition in frames. 0 means an instant snap.

0
ease EasingFunctionT

Easing function.

linear_in_out
animation_type AnimationType

How to combine with the base value.

ABSOLUTE
Source code in src/keyed/builders.py
def at(
    self,
    frame: int,
    to: HasValue[T],
    over: int = 0,
    ease: EasingFunctionT = linear_in_out,
    animation_type: AnimationType = AnimationType.ABSOLUTE,
) -> Cues[T]:
    """Add a cue: start transitioning to ``to`` at frame ``frame`` over ``over`` frames.

    Args:
        frame: Frame at which the transition begins.
        to: Target value.
        over: Duration of the transition in frames. ``0`` means an instant snap.
        ease: Easing function.
        animation_type: How to combine with the base value.
    """
    self._cues.append(_Cue(at=frame, to=to, over=over, ease=ease, animation_type=animation_type))
    return self

snap

snap(frame, to, animation_type=ABSOLUTE)

Add an instant snap to to at frame frame.

Shorthand for .at(frame, to, over=0).

Source code in src/keyed/builders.py
def snap(
    self,
    frame: int,
    to: HasValue[T],
    animation_type: AnimationType = AnimationType.ABSOLUTE,
) -> Cues[T]:
    """Add an instant snap to ``to`` at frame ``frame``.

    Shorthand for ``.at(frame, to, over=0)``.
    """
    return self.at(frame=frame, to=to, over=0, animation_type=animation_type)

build

build(frame=None)

Build and return the reactive animation value.

Parameters:

Name Type Description Default
frame ReactiveValue[int] | None

Frame signal to evaluate against. Defaults to the active Scene's frame.

None
Source code in src/keyed/builders.py
def build(self, frame: ReactiveValue[int] | None = None) -> ReactiveValue[T]:
    """Build and return the reactive animation value.

    Args:
        frame: Frame signal to evaluate against. Defaults to the active Scene's frame.
    """
    cues = sorted(self._cues, key=lambda k: k.at)
    result: HasValue[T] = self._initial
    prev_value: HasValue[T] = self._initial

    for cue in cues:
        anim = Animation(
            start=cue.at,
            end=cue.at + cue.over,
            start_value=prev_value,
            end_value=cue.to,
            ease=cue.ease,
            animation_type=cue.animation_type,
        )
        result = anim(result, frame)
        prev_value = cue.to

    return as_rx(result)

Flow

Bases: Generic[T]

Build an animation as a forward-only sequence of relative transitions.

  • tween adds a smooth transition over a duration.
  • hold advances the cursor without animating.
  • snap applies an instant change at the current cursor.

The cursor starts at at and advances with each tween and hold call. After the optional initial anchor, no absolute frame numbers appear in the method arguments.

Use this builder when you want to describe what happens next without doing frame arithmetic by hand.

Parameters:

Name Type Description Default
value HasValue[T]

Initial value (before any transitions).

required
at int

Starting frame for the first transition. Defaults to 0.

0

Examples:

from keyed import Color, easing
from keyed.builders import Flow

black = Color(0, 0, 0)
yellow = Color(0.75, 0.75, 0)

x_color = (
    Flow(black, at=120)
    .tween(12, yellow, ease=easing.cubic_in_out)
    .hold(24)
    .tween(12, black)
    .build()
)
Source code in src/keyed/builders.py
class Flow(Generic[T]):
    """Build an animation as a forward-only sequence of relative transitions.

    - [`tween`][keyed.builders.Flow.tween] adds a smooth transition over a
      duration.
    - [`hold`][keyed.builders.Flow.hold] advances the cursor without animating.
    - [`snap`][keyed.builders.Flow.snap] applies an instant change at the
      current cursor.

    The cursor starts at `at` and advances with each
    [`tween`][keyed.builders.Flow.tween] and
    [`hold`][keyed.builders.Flow.hold] call. After the optional initial anchor,
    no absolute frame numbers appear in the method arguments.

    Use this builder when you want to describe what happens next without doing
    frame arithmetic by hand.

    Args:
        value: Initial value (before any transitions).
        at: Starting frame for the first transition. Defaults to ``0``.

    Examples:
        ```python
        from keyed import Color, easing
        from keyed.builders import Flow

        black = Color(0, 0, 0)
        yellow = Color(0.75, 0.75, 0)

        x_color = (
            Flow(black, at=120)
            .tween(12, yellow, ease=easing.cubic_in_out)
            .hold(24)
            .tween(12, black)
            .build()
        )
        ```
    """

    def __init__(self, value: HasValue[T], at: int = 0) -> None:
        self._initial = value
        self._cursor = at
        self._current_value: HasValue[T] = value
        self._segments: list[_Segment] = []

    def tween(
        self,
        duration: int,
        to: HasValue[T],
        ease: EasingFunctionT = linear_in_out,
        animation_type: AnimationType = AnimationType.ABSOLUTE,
    ) -> Flow[T]:
        """Smoothly animate to ``to`` over ``duration`` frames.

        Advances the cursor by ``duration`` frames.

        Args:
            duration: Number of frames for the transition.
            to: Target value.
            ease: Easing function.
            animation_type: How to combine with the base value.
        """
        self._segments.append(
            _Segment(
                start=self._cursor,
                end=self._cursor + duration,
                from_=self._current_value,
                to=to,
                ease=ease,
                animation_type=animation_type,
            )
        )
        self._cursor += duration
        self._current_value = to
        return self

    def hold(self, duration: int) -> Flow[T]:
        """Advance the cursor by ``duration`` frames without animating."""
        self._cursor += duration
        return self

    def snap(
        self,
        to: HasValue[T],
        animation_type: AnimationType = AnimationType.ABSOLUTE,
    ) -> Flow[T]:
        """Instantly snap to ``to`` at the current cursor. Cursor does not advance.

        Shorthand for ``.tween(0, to)``.
        """
        return self.tween(0, to, animation_type=animation_type)

    def build(self, frame: ReactiveValue[int] | None = None) -> ReactiveValue[T]:
        """Build and return the reactive animation value.

        Args:
            frame: Frame signal to evaluate against. Defaults to the active Scene's frame.
        """
        result: HasValue[T] = self._initial
        for seg in self._segments:
            anim = Animation(
                start=seg.start,
                end=seg.end,
                start_value=seg.from_,
                end_value=seg.to,
                ease=seg.ease,
                animation_type=seg.animation_type,
            )
            result = anim(result, frame)  # type: ignore[assignment]
        return as_rx(result)

tween

tween(duration, to, ease=linear_in_out, animation_type=ABSOLUTE)

Smoothly animate to to over duration frames.

Advances the cursor by duration frames.

Parameters:

Name Type Description Default
duration int

Number of frames for the transition.

required
to HasValue[T]

Target value.

required
ease EasingFunctionT

Easing function.

linear_in_out
animation_type AnimationType

How to combine with the base value.

ABSOLUTE
Source code in src/keyed/builders.py
def tween(
    self,
    duration: int,
    to: HasValue[T],
    ease: EasingFunctionT = linear_in_out,
    animation_type: AnimationType = AnimationType.ABSOLUTE,
) -> Flow[T]:
    """Smoothly animate to ``to`` over ``duration`` frames.

    Advances the cursor by ``duration`` frames.

    Args:
        duration: Number of frames for the transition.
        to: Target value.
        ease: Easing function.
        animation_type: How to combine with the base value.
    """
    self._segments.append(
        _Segment(
            start=self._cursor,
            end=self._cursor + duration,
            from_=self._current_value,
            to=to,
            ease=ease,
            animation_type=animation_type,
        )
    )
    self._cursor += duration
    self._current_value = to
    return self

hold

hold(duration)

Advance the cursor by duration frames without animating.

Source code in src/keyed/builders.py
def hold(self, duration: int) -> Flow[T]:
    """Advance the cursor by ``duration`` frames without animating."""
    self._cursor += duration
    return self

snap

snap(to, animation_type=ABSOLUTE)

Instantly snap to to at the current cursor. Cursor does not advance.

Shorthand for .tween(0, to).

Source code in src/keyed/builders.py
def snap(
    self,
    to: HasValue[T],
    animation_type: AnimationType = AnimationType.ABSOLUTE,
) -> Flow[T]:
    """Instantly snap to ``to`` at the current cursor. Cursor does not advance.

    Shorthand for ``.tween(0, to)``.
    """
    return self.tween(0, to, animation_type=animation_type)

build

build(frame=None)

Build and return the reactive animation value.

Parameters:

Name Type Description Default
frame ReactiveValue[int] | None

Frame signal to evaluate against. Defaults to the active Scene's frame.

None
Source code in src/keyed/builders.py
def build(self, frame: ReactiveValue[int] | None = None) -> ReactiveValue[T]:
    """Build and return the reactive animation value.

    Args:
        frame: Frame signal to evaluate against. Defaults to the active Scene's frame.
    """
    result: HasValue[T] = self._initial
    for seg in self._segments:
        anim = Animation(
            start=seg.start,
            end=seg.end,
            start_value=seg.from_,
            end_value=seg.to,
            ease=seg.ease,
            animation_type=seg.animation_type,
        )
        result = anim(result, frame)  # type: ignore[assignment]
    return as_rx(result)

Keys

Bases: Generic[T]

Build an animation by placing absolute keys on the timeline.

Each key names a frame, a value, and how to arrive there from the previous key:

  • snap holds the current value, then changes instantly at the named frame.
  • tween interpolates from the previous key to this one, with the full gap acting as the duration.

There is no over argument anywhere. Transition duration is determined implicitly by the gap between consecutive keys.

Use hold to anchor the current value at a frame so the next tween starts there instead of at the previous key.

Parameters:

Name Type Description Default
initial HasValue[T]

The value before any keys are placed.

required

Examples:

from keyed import Color, easing
from keyed.builders import Keys

black = Color(0, 0, 0)
yellow = Color(0.75, 0.75, 0)
red = Color(0.75, 0, 0)

x_color = (
    Keys(black)
    .hold(108)
    .tween(120, yellow, ease=easing.cubic_in_out)
    .hold(156)
    .tween(300, red, ease=easing.linear_in_out)
    .build()
)
Source code in src/keyed/builders.py
class Keys(Generic[T]):
    """Build an animation by placing absolute keys on the timeline.

    Each key names a frame, a value, and how to arrive there from the previous
    key:

    - [`snap`][keyed.builders.Keys.snap] holds the current value, then changes
      instantly at the named frame.
    - [`tween`][keyed.builders.Keys.tween] interpolates from the previous key to
      this one, with the full gap acting as the duration.

    There is no `over` argument anywhere. Transition duration is determined
    implicitly by the gap between consecutive keys.

    Use [`hold`][keyed.builders.Keys.hold] to anchor the current value at a
    frame so the next tween starts there instead of at the previous key.

    Args:
        initial: The value before any keys are placed.

    Examples:
        ```python
        from keyed import Color, easing
        from keyed.builders import Keys

        black = Color(0, 0, 0)
        yellow = Color(0.75, 0.75, 0)
        red = Color(0.75, 0, 0)

        x_color = (
            Keys(black)
            .hold(108)
            .tween(120, yellow, ease=easing.cubic_in_out)
            .hold(156)
            .tween(300, red, ease=easing.linear_in_out)
            .build()
        )
        ```
    """

    def __init__(self, initial: HasValue[T]) -> None:
        self._initial = initial
        self._keys: list[_Key] = []

    def snap(
        self,
        frame: int,
        to: HasValue[T],
        animation_type: AnimationType = AnimationType.ABSOLUTE,
    ) -> Keys[T]:
        """Hold at the current value and snap instantly to ``to`` at ``frame``.

        Args:
            frame: The frame at which the snap occurs.
            to: The value to snap to.
            animation_type: How to combine with the base value.
        """
        self._keys.append(_Key(frame=frame, value=to, ease=None, animation_type=animation_type))
        return self

    def hold(self, frame: int) -> Keys[T]:
        """Anchor the current value at ``frame`` so the next tween starts there.

        Use this to delay the start of a smooth transition. The value stays
        constant up to ``frame``, and any subsequent
        [`tween`][keyed.builders.Keys.tween] begins from there rather than from
        the previous key.

        Args:
            frame: Frame at which to anchor the current value.
        """
        self._keys.append(_Key(frame=frame, value=_INHERIT, ease=None, animation_type=AnimationType.ABSOLUTE))
        return self

    def tween(
        self,
        frame: int,
        to: HasValue[T],
        ease: EasingFunctionT = linear_in_out,
        animation_type: AnimationType = AnimationType.ABSOLUTE,
    ) -> Keys[T]:
        """Smoothly interpolate from the previous keyframe to ``to`` at ``frame``.

        The full gap between the previous keyframe (or frame 0) and ``frame`` is
        the transition duration.

        Args:
            frame: The frame at which the value fully arrives.
            to: The target value.
            ease: Easing function for the transition.
            animation_type: How to combine with the base value.
        """
        self._keys.append(_Key(frame=frame, value=to, ease=ease, animation_type=animation_type))
        return self

    def build(self, frame: ReactiveValue[int] | None = None) -> ReactiveValue[T]:
        """Build and return the reactive animation value.

        Args:
            frame: Frame signal to evaluate against. Defaults to the active Scene's frame.
        """
        keys = sorted(self._keys, key=lambda m: m.frame)
        result: HasValue[T] = self._initial
        prev_frame: int = 0
        prev_value: HasValue[T] = self._initial

        for key in keys:
            actual_value = prev_value if key.value is _INHERIT else key.value
            if key.ease is None:
                anim = Animation(
                    start=key.frame,
                    end=key.frame,
                    start_value=actual_value,
                    end_value=actual_value,
                    animation_type=key.animation_type,
                )
            else:
                anim = Animation(
                    start=prev_frame,
                    end=key.frame,
                    start_value=prev_value,
                    end_value=actual_value,
                    ease=key.ease,
                    animation_type=key.animation_type,
                )
            result = anim(result, frame)  # type: ignore[assignment]
            prev_frame = key.frame
            prev_value = actual_value

        return as_rx(result)

snap

snap(frame, to, animation_type=ABSOLUTE)

Hold at the current value and snap instantly to to at frame.

Parameters:

Name Type Description Default
frame int

The frame at which the snap occurs.

required
to HasValue[T]

The value to snap to.

required
animation_type AnimationType

How to combine with the base value.

ABSOLUTE
Source code in src/keyed/builders.py
def snap(
    self,
    frame: int,
    to: HasValue[T],
    animation_type: AnimationType = AnimationType.ABSOLUTE,
) -> Keys[T]:
    """Hold at the current value and snap instantly to ``to`` at ``frame``.

    Args:
        frame: The frame at which the snap occurs.
        to: The value to snap to.
        animation_type: How to combine with the base value.
    """
    self._keys.append(_Key(frame=frame, value=to, ease=None, animation_type=animation_type))
    return self

hold

hold(frame)

Anchor the current value at frame so the next tween starts there.

Use this to delay the start of a smooth transition. The value stays constant up to frame, and any subsequent tween begins from there rather than from the previous key.

Parameters:

Name Type Description Default
frame int

Frame at which to anchor the current value.

required
Source code in src/keyed/builders.py
def hold(self, frame: int) -> Keys[T]:
    """Anchor the current value at ``frame`` so the next tween starts there.

    Use this to delay the start of a smooth transition. The value stays
    constant up to ``frame``, and any subsequent
    [`tween`][keyed.builders.Keys.tween] begins from there rather than from
    the previous key.

    Args:
        frame: Frame at which to anchor the current value.
    """
    self._keys.append(_Key(frame=frame, value=_INHERIT, ease=None, animation_type=AnimationType.ABSOLUTE))
    return self

tween

tween(frame, to, ease=linear_in_out, animation_type=ABSOLUTE)

Smoothly interpolate from the previous keyframe to to at frame.

The full gap between the previous keyframe (or frame 0) and frame is the transition duration.

Parameters:

Name Type Description Default
frame int

The frame at which the value fully arrives.

required
to HasValue[T]

The target value.

required
ease EasingFunctionT

Easing function for the transition.

linear_in_out
animation_type AnimationType

How to combine with the base value.

ABSOLUTE
Source code in src/keyed/builders.py
def tween(
    self,
    frame: int,
    to: HasValue[T],
    ease: EasingFunctionT = linear_in_out,
    animation_type: AnimationType = AnimationType.ABSOLUTE,
) -> Keys[T]:
    """Smoothly interpolate from the previous keyframe to ``to`` at ``frame``.

    The full gap between the previous keyframe (or frame 0) and ``frame`` is
    the transition duration.

    Args:
        frame: The frame at which the value fully arrives.
        to: The target value.
        ease: Easing function for the transition.
        animation_type: How to combine with the base value.
    """
    self._keys.append(_Key(frame=frame, value=to, ease=ease, animation_type=animation_type))
    return self

build

build(frame=None)

Build and return the reactive animation value.

Parameters:

Name Type Description Default
frame ReactiveValue[int] | None

Frame signal to evaluate against. Defaults to the active Scene's frame.

None
Source code in src/keyed/builders.py
def build(self, frame: ReactiveValue[int] | None = None) -> ReactiveValue[T]:
    """Build and return the reactive animation value.

    Args:
        frame: Frame signal to evaluate against. Defaults to the active Scene's frame.
    """
    keys = sorted(self._keys, key=lambda m: m.frame)
    result: HasValue[T] = self._initial
    prev_frame: int = 0
    prev_value: HasValue[T] = self._initial

    for key in keys:
        actual_value = prev_value if key.value is _INHERIT else key.value
        if key.ease is None:
            anim = Animation(
                start=key.frame,
                end=key.frame,
                start_value=actual_value,
                end_value=actual_value,
                animation_type=key.animation_type,
            )
        else:
            anim = Animation(
                start=prev_frame,
                end=key.frame,
                start_value=prev_value,
                end_value=actual_value,
                ease=key.ease,
                animation_type=key.animation_type,
            )
        result = anim(result, frame)  # type: ignore[assignment]
        prev_frame = key.frame
        prev_value = actual_value

    return as_rx(result)