From f81d5713b3cb565e23bcb9bbfe19d547d040c122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Manr=C3=ADquez=20Novoa?= <49853152+chopan050@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:00:54 -0300 Subject: [PATCH] Allow animations with `run_time=0` and implement convenience `Add` animation (#4017) * Allow animations with run_time=0 and implement convenience Add animation * Modify examples so that there are less characters per line * Docstring fixes * Update manim/animation/animation.py * Address comments --------- Co-authored-by: Benjamin Hackl --- manim/animation/animation.py | 133 +++++++++++++++++------ manim/animation/composition.py | 10 +- manim/scene/scene.py | 41 ++++++- tests/module/animation/test_animation.py | 43 ++++++-- 4 files changed, 177 insertions(+), 50 deletions(-) diff --git a/manim/animation/animation.py b/manim/animation/animation.py index e090d99059..491d573740 100644 --- a/manim/animation/animation.py +++ b/manim/animation/animation.py @@ -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 @@ -172,6 +172,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") @@ -194,7 +207,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 @@ -569,33 +581,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. @@ -638,7 +623,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 diff --git a/manim/animation/composition.py b/manim/animation/composition.py index 36e9e1d6e3..1391dbfd1b 100644 --- a/manim/animation/composition.py +++ b/manim/animation/composition.py @@ -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 @@ -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() @@ -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) @@ -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: diff --git a/manim/scene/scene.py b/manim/scene/scene.py index a05413184c..e70c3beb14 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -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 @@ -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. @@ -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, @@ -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, @@ -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): @@ -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( diff --git a/tests/module/animation/test_animation.py b/tests/module/animation/test_animation.py index fb2efc7853..5991aab074 100644 --- a/tests/module/animation/test_animation.py +++ b/tests/module/animation/test_animation.py @@ -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): @@ -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)