Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow animations with run_time=0 and implement convenience Add animation #4017

Merged
merged 8 commits into from
Nov 27, 2024
134 changes: 102 additions & 32 deletions manim/animation/animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@
from .. import config, logger
from ..constants import RendererType
from ..mobject import mobject
from ..mobject.mobject import Mobject
from ..mobject.mobject import Group, Mobject
from ..mobject.opengl import opengl_mobject
from ..utils.rate_functions import linear, smooth

__all__ = ["Animation", "Wait", "override_animation"]
__all__ = ["Animation", "Wait", "Add", "override_animation"]


from collections.abc import Iterable, Sequence
from copy import deepcopy
from functools import partialmethod
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Any, Callable

from typing_extensions import Self

Expand Down Expand Up @@ -144,6 +144,7 @@ def __init__(
**kwargs,
) -> None:
self._typecheck_input(mobject)
self._run_time: float = run_time
self.run_time: float = run_time
self.rate_func: Callable[[float], float] = rate_func
self.reverse_rate_function: bool = reverse_rate_function
Expand Down Expand Up @@ -172,6 +173,19 @@ def __init__(
),
)

@property
def run_time(self) -> float:
return self._run_time

@run_time.setter
def run_time(self, value: float) -> None:
if value < 0:
raise ValueError(
f"The run_time of {self.__class__.__name__} cannot be "
f"negative. The given value was {value}."
)
self._run_time = value

def _typecheck_input(self, mobject: Mobject | None) -> None:
if mobject is None:
logger.debug("Animation with empty mobject")
Expand All @@ -194,7 +208,6 @@ def begin(self) -> None:
method.

"""
self.run_time = validate_run_time(self.run_time, str(self))
self.starting_mobject = self.create_starting_mobject()
if self.suspend_mobject_updating:
# All calls to self.mobject's internal updaters
Expand Down Expand Up @@ -569,33 +582,6 @@ def prepare_animation(
raise TypeError(f"Object {anim} cannot be converted to an animation")


def validate_run_time(
run_time: float, caller_name: str, parameter_name: str = "run_time"
) -> float:
if run_time <= 0:
raise ValueError(
f"{caller_name} has a {parameter_name} of {run_time:g} <= 0 "
f"seconds which Manim cannot render. Please set the "
f"{parameter_name} to a positive number."
)

# config.frame_rate holds the number of frames per second
fps = config.frame_rate
seconds_per_frame = 1 / fps
if run_time < seconds_per_frame:
logger.warning(
f"The original {parameter_name} of {caller_name}, {run_time:g} "
f"seconds, is too short for the current frame rate of {fps:g} "
f"FPS. Rendering with the shortest possible {parameter_name} of "
f"{seconds_per_frame:g} seconds instead."
)
new_run_time = seconds_per_frame
else:
new_run_time = run_time

return new_run_time


class Wait(Animation):
"""A "no operation" animation.

Expand Down Expand Up @@ -638,7 +624,91 @@ def __init__(
self.mobject.shader_wrapper_list = []

def begin(self) -> None:
self.run_time = validate_run_time(self.run_time, str(self))
pass

def finish(self) -> None:
pass

def clean_up_from_scene(self, scene: Scene) -> None:
pass

def update_mobjects(self, dt: float) -> None:
pass

def interpolate(self, alpha: float) -> None:
pass


class Add(Animation):
"""Add Mobjects to a scene, without animating them in any other way. This
is similar to the :meth:`.Scene.add` method, but :class:`Add` is an
animation which can be grouped into other animations.

Parameters
----------
mobjects
One :class:`Mobject` or more to add to a scene.
run_time
The duration of the animation after adding the ``mobjects``. Defaults
to 0, which means this is an instant animation without extra wait time
after adding them.
**kwargs
Additional arguments to pass to the parent :class:`Animation` class.

Examples
--------

.. manim:: DefaultAddScene

class DefaultAddScene(Scene):
def construct(self):
text_1 = Text("I was added with Add!")
text_2 = Text("Me too!")
text_3 = Text("And me!")
texts = VGroup(text_1, text_2, text_3).arrange(DOWN)
rect = SurroundingRectangle(texts, buff=0.5)

self.play(
Create(rect, run_time=3.0),
Succession(
Wait(1.0),
# You can Add a Mobject in the middle of an animation...
Add(text_1),
Wait(1.0),
# ...or multiple Mobjects at once!
Add(text_2, text_3),
),
)
self.wait()

.. manim:: AddWithRunTimeScene

class AddWithRunTimeScene(Scene):
def construct(self):
# A 5x5 grid of circles
circles = VGroup(
*[Circle(radius=0.5) for _ in range(25)]
).arrange_in_grid(5, 5)

self.play(
Succession(
# Add a run_time of 0.2 to wait for 0.2 seconds after
# adding the circle, instead of using Wait(0.2) after Add!
*[Add(circle, run_time=0.2) for circle in circles],
rate_func=smooth,
)
)
self.wait()
"""

def __init__(
self, *mobjects: Mobject, run_time: float = 0.0, **kwargs: Any
) -> None:
mobject = mobjects[0] if len(mobjects) == 1 else Group(*mobjects)
super().__init__(mobject, run_time=run_time, introducer=True, **kwargs)

def begin(self) -> None:
pass

def finish(self) -> None:
pass
Expand Down
10 changes: 5 additions & 5 deletions manim/animation/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import numpy as np

from manim._config import config
from manim.animation.animation import Animation, prepare_animation, validate_run_time
from manim.animation.animation import Animation, prepare_animation
from manim.constants import RendererType
from manim.mobject.mobject import Group, Mobject
from manim.mobject.opengl.opengl_mobject import OpenGLGroup
Expand Down Expand Up @@ -87,7 +87,6 @@ def begin(self) -> None:
f"Trying to play {self} without animations, this is not supported. "
"Please add at least one subanimation."
)
self.run_time = validate_run_time(self.run_time, str(self))
self.anim_group_time = 0.0
if self.suspend_mobject_updating:
self.group.suspend_updating()
Expand Down Expand Up @@ -175,11 +174,13 @@ def interpolate(self, alpha: float) -> None:
]

run_times = to_update["end"] - to_update["start"]
with_zero_run_time = run_times == 0
run_times[with_zero_run_time] = 1
sub_alphas = (anim_group_time - to_update["start"]) / run_times
if time_goes_back:
sub_alphas[sub_alphas < 0] = 0
sub_alphas[(sub_alphas < 0) | with_zero_run_time] = 0
else:
sub_alphas[sub_alphas > 1] = 1
sub_alphas[(sub_alphas > 1) | with_zero_run_time] = 1

for anim_to_update, sub_alpha in zip(to_update["anim"], sub_alphas):
anim_to_update.interpolate(sub_alpha)
Expand Down Expand Up @@ -235,7 +236,6 @@ def begin(self) -> None:
f"Trying to play {self} without animations, this is not supported. "
"Please add at least one subanimation."
)
self.run_time = validate_run_time(self.run_time, str(self))
self.update_active_animation(0)

def finish(self) -> None:
Expand Down
41 changes: 36 additions & 5 deletions manim/scene/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from manim.mobject.opengl.opengl_mobject import OpenGLPoint

from .. import config, logger
from ..animation.animation import Animation, Wait, prepare_animation, validate_run_time
from ..animation.animation import Animation, Wait, prepare_animation
from ..camera.camera import Camera
from ..constants import *
from ..gui.gui import configure_pygui
Expand Down Expand Up @@ -1020,6 +1020,35 @@ def get_time_progression(
)
return time_progression

@classmethod
def validate_run_time(
cls,
run_time: float,
method: Callable[[Any, ...], Any],
parameter_name: str = "run_time",
) -> float:
method_name = f"{cls.__name__}.{method.__name__}()"
if run_time <= 0:
raise ValueError(
f"{method_name} has a {parameter_name} of "
f"{run_time:g} <= 0 seconds which Manim cannot render. "
f"The {parameter_name} must be a positive number."
)

# config.frame_rate holds the number of frames per second
fps = config.frame_rate
seconds_per_frame = 1 / fps
if run_time < seconds_per_frame:
logger.warning(
f"The original {parameter_name} of {method_name}, "
f"{run_time:g} seconds, is too short for the current frame "
f"rate of {fps:g} FPS. Rendering with the shortest possible "
f"{parameter_name} of {seconds_per_frame:g} seconds instead."
)
run_time = seconds_per_frame

return run_time

def get_run_time(self, animations: list[Animation]):
"""
Gets the total run time for a list of animations.
Expand All @@ -1035,7 +1064,9 @@ def get_run_time(self, animations: list[Animation]):
float
The total ``run_time`` of all of the animations in the list.
"""
return max(animation.run_time for animation in animations)
run_time = max(animation.run_time for animation in animations)
run_time = self.validate_run_time(run_time, self.play, "total run_time")
return run_time

def play(
self,
Expand Down Expand Up @@ -1131,7 +1162,7 @@ def wait(
--------
:class:`.Wait`, :meth:`.should_mobjects_update`
"""
duration = validate_run_time(duration, str(self) + ".wait()", "duration")
duration = self.validate_run_time(duration, self.wait, "duration")
self.play(
Wait(
run_time=duration,
Expand All @@ -1155,7 +1186,7 @@ def pause(self, duration: float = DEFAULT_WAIT_TIME):
--------
:meth:`.wait`, :class:`.Wait`
"""
duration = validate_run_time(duration, str(self) + ".pause()", "duration")
duration = self.validate_run_time(duration, self.pause, "duration")
self.wait(duration=duration, frozen_frame=True)

def wait_until(self, stop_condition: Callable[[], bool], max_time: float = 60):
Expand All @@ -1169,7 +1200,7 @@ def wait_until(self, stop_condition: Callable[[], bool], max_time: float = 60):
max_time
The maximum wait time in seconds.
"""
max_time = validate_run_time(max_time, str(self) + ".wait_until()", "max_time")
max_time = self.validate_run_time(max_time, self.wait_until, "max_time")
self.wait(max_time, stop_condition=stop_condition)

def compile_animation_data(
Expand Down
43 changes: 35 additions & 8 deletions tests/module/animation/test_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@
from manim import FadeIn, Scene


@pytest.mark.parametrize(
"run_time",
[0, -1],
)
def test_animation_forbidden_run_time(run_time):
def test_animation_zero_total_run_time():
test_scene = Scene()
with pytest.raises(
ValueError, match="Please set the run_time to a positive number."
ValueError, match="The total run_time must be a positive number."
):
test_scene.play(FadeIn(None, run_time=run_time))
test_scene.play(FadeIn(None, run_time=0))


def test_single_animation_zero_run_time_with_more_animations():
test_scene = Scene()
test_scene.play(FadeIn(None, run_time=0), FadeIn(None, run_time=1))


def test_animation_negative_run_time():
with pytest.raises(ValueError, match="The run_time of FadeIn cannot be negative."):
FadeIn(None, run_time=-1)


def test_animation_run_time_shorter_than_frame_rate(manim_caplog, config):
Expand All @@ -23,8 +29,29 @@ def test_animation_run_time_shorter_than_frame_rate(manim_caplog, config):
assert "too short for the current frame rate" in manim_caplog.text


@pytest.mark.parametrize("duration", [0, -1])
def test_wait_invalid_duration(duration):
test_scene = Scene()
with pytest.raises(ValueError, match="The duration must be a positive number."):
test_scene.wait(duration)


@pytest.mark.parametrize("frozen_frame", [False, True])
def test_wait_run_time_shorter_than_frame_rate(manim_caplog, frozen_frame):
def test_wait_duration_shorter_than_frame_rate(manim_caplog, frozen_frame):
test_scene = Scene()
test_scene.wait(1e-9, frozen_frame=frozen_frame)
assert "too short for the current frame rate" in manim_caplog.text


@pytest.mark.parametrize("duration", [0, -1])
def test_pause_invalid_duration(duration):
test_scene = Scene()
with pytest.raises(ValueError, match="The duration must be a positive number."):
test_scene.pause(duration)


@pytest.mark.parametrize("max_time", [0, -1])
def test_wait_until_invalid_max_time(max_time):
test_scene = Scene()
with pytest.raises(ValueError, match="The max_time must be a positive number."):
test_scene.wait_until(lambda: True, max_time)
Loading