ManimCommunity / manim

A community-maintained Python framework for creating mathematical animations.
https://www.manim.community
MIT License
26.7k stars 1.83k forks source link

call finish() when animations finishes #3951

Open Laifsyn opened 1 month ago

Laifsyn commented 1 month ago

Fix Animation's getting toggled off but not back on

Overview: What does this pull request change?

Animations sent through AnimationGroup and LaggedStart will have their updaters toggled off without toggling them back on after the GroupAnimation finishes.

Motivation and Explanation: Why and how do your changes improve the library?

Users playing the animations of their Mobjects in AnimationGroup will find themselves troubled due to updaters silently getting turned off but not back on after the AnimationGroup finishes

Links to added or changed documentation pages

N/A

Further Information and Comments

This could be a fix to the issue found in https://github.com/ManimCommunity/manim/issues/3950

Reviewer Checklist

Laifsyn commented 1 month ago

If you want to skip the Wall Texts go to suggested solutions.

You can also check for the Issue at this next link: https://github.com/ManimCommunity/manim/issues/3950 "Manim 0.18.1: Updaters don't work with objects which have been added using LaggedStart()"

So based on the method finish(self) from the class Animation which is located at animation.py file(I'll call it Animation::finish(self)@animation.py for now), if the animation was set to suspend the mobject's updaters, which happens when calling Animation::begin(self)@animation.py,the updaters won't be "unsuspended" until Animation::finish(self), which checks for the self.suspend_mobject_updating : bool attribute, is called.

However AnimationGroup(Animation)::finish(self) is overriden, and in its place we have the following snippet Code Block #1, which we can see that what's being resumed is actually AnimationGroup(Animation)::group : Group | VGroup | OpenGLGroup | OpenGLVGroup which at this moment of time, I could say it's a list of Animations whose Animation::introducer : bool property returns bool (Check Snippet #1 to see AnimationGroup(Animation)::__init__(self), and how animations are being added to AnimationGroup(Animation)::group).

If we see at the tested_code, and follow the definitions to Create(ShowPartial)::__init__(self)@creation.py we can see that the __init__(self) does set introducer to True as default (Also worth to mention I tried to run LaggedStart(....,introducer=True)). So the issue at hand might not necessarilly be as straightforward due to design decisions which can be traced to https://github.com/ManimCommunity/manim/issues/2396 (worth to mention I didn't dive deep into the commit's details), so updating the default of Create() to introducer=False should undoubtedly be out of question.

Why false? Because at AnimatioGroup(Animation)::__init__(self) we have this snippet: [anim.mobject for anim in self.animations if not anim.is_introducer()] which negates the Animation::introducer property.

Solutions

So here's 3 possible solutions

Solution 1:

Update AnimationGroup(Animation)::__init__(), re-defining self.group definition

This approach might cause breaking changes because I (or we) don't know what's self.group is actually used for.

# Copied from `AnimationGroup(Animation)::__init__(self)`
arg_anim = flatten_iterable_parameters(animations)
self.animations = [prepare_animation(anim) for anim in arg_anim]
self.rate_func = rate_func
self.group = group
if self.group is None:
    mobjects = remove_list_redundancies(
        # [anim.mobject for anim in self.animations if not anim.is_introducer()], # <---- Old Line
        [anim.mobject for anim in self.animations if anim.is_introducer()], # (1) Invert the negation
        # (2) Or alternatively remove the `if` condition altogether
    )
    if config["renderer"] == RendererType.OPENGL:
        self.group = OpenGLGroup(*mobjects)
    else:
        self.group = Group(*mobjects)
super().__init__(
    self.group, rate_func=self.rate_func, lag_ratio=lag_ratio, **kwargs
)
self.run_time: float = self.init_run_time(run_time)

Solution 2:

Update the AnimationGroup(Animation)::finish(self) definition to individually call Animation::mobject.resume_updating() on each item of AnimationGroup(Animation)::animations: list[Animation]. (or alternatively call the .finish() method on each animation (check Solution 3))

# Copied from `AnimationGroup(Animation)::finish(self)`
    def finish(self) -> None:
        self.interpolate(1)
        self.anims_begun[:] = True
        self.anims_finished[:] = True
        super().finish()
        if self.suspend_mobject_updating:
            for anim in self.animations:        # <----- Added Line
                anim.mobject.resume_updating()  # <----- Added Line
            self.group.resume_updating()

Solution 3:

call .finish() method on each individual AnimationGroup(Animation)::animations: list[Animation] (or check Solution 1 where I suggest updating the self.group attribute definition)

We would be re-adding code that was removed in PR: https://github.com/ManimCommunity/manim/pull/3542 (Dating back to 2023 December)

# Copied from `AnimationGroup(Animation)::finish(self)`
    def finish(self) -> None:
        self.interpolate(1)
        self.anims_begun[:] = True
        self.anims_finished[:] = True
        super().finish()
        if self.suspend_mobject_updating:
            for anim in self.animations:    # <----- Added Line
                anim.finish()               # <----- Added Line
            self.group.resume_updating()


Code Block 1

Current finish(self) implementation

# Copied from AnimationGroup(Animation)::finish(self)
def finish(self) -> None:
    self.interpolate(1)
    self.anims_begun[:] = True
    self.anims_finished[:] = True
    if self.suspend_mobject_updating:
        self.group.resume_updating()

Code Block 2


# 1 (Let's call animation.py as the base file)
# `# 1` emulates the hand-written callstack, and its our base call

# 1
def finish(self) -> None: # Animation::finish(self) @ animation.py
    """Finish the animation.

    This method gets called when the animation is over.

    """
    self.interpolate(1)
    if self.suspend_mobject_updating and self.mobject is not None:
        self.mobject.resume_updating()

# 2
def interpolate(self, alpha: float) -> None: # Animation::interpolate(self) @ animation.py
    """Set the animation progress.

    This method gets called for every frame during an animation.

    Parameters
    ----------
    alpha
        The relative time to set the animation to, 0 meaning the start, 1 meaning
        the end.
    """
    self.interpolate_mobject(alpha)
# 2 (Foreign File)
def resume_updating(self, recursive: bool = True) -> Self: # Mobject::resume_updating(self) @ mobject.py
    self.updating_suspended = False
    if recursive:
        for submob in self.submobjects:
            submob.resume_updating(recursive)
    self.update(dt=0, recursive=recursive)
    return self

# 3 
def interpolate_mobject(self, alpha: float) -> None: # Animation::interpolate_mobject(self) @ animation.py
    """Interpolates the mobject of the :class:`Animation` based on alpha value.

    Parameters
    ----------
    alpha
        A float between 0 and 1 expressing the ratio to which the animation
        is completed. For example, alpha-values of 0, 0.5, and 1 correspond
        to the animation being completed 0%, 50%, and 100%, respectively.
    """
    families = list(self.get_all_families_zipped())
    for i, mobs in enumerate(families):
        sub_alpha = self.get_sub_alpha(alpha, i, len(families))
        self.interpolate_submobject(*mobs, sub_alpha)

# 4 (Does nothing (?))
def interpolate_submobject(
    self,
    submobject: Mobject,
    starting_submobject: Mobject,
    alpha: float,
) -> Animation: # Animation::interpolate_submobject(self) @ animation.py
    pass
# 4 
def get_sub_alpha(self, alpha: float, index: int, num_submobjects: int) -> float: # Animation::get_sub_alpha(self) @ animation.py
    """Get the animation progress of any submobjects subanimation.

    Parameters
    ----------
    alpha
        The overall animation progress
    index
        The index of the subanimation.
    num_submobjects
        The total count of subanimations.

    Returns
    -------
    float
        The progress of the subanimation.
    """
    lag_ratio = self.lag_ratio
    full_length = (num_submobjects - 1) * lag_ratio + 1
    value = alpha * full_length
    lower = index * lag_ratio
    if self.reverse_rate_function:
        # self.rate_func: Callable[[float], float]
        # default callable: rate_functions.smooth
        # Set at Animation::__init__(self) @ animation.py
        return self.rate_func(1 - (value - lower))
    else:
        return self.rate_func(value - lower)

Snippet 1


def __init__(
    self,
    *animations: Animation | Iterable[Animation] | types.GeneratorType[Animation],
    group: Group | VGroup | OpenGLGroup | OpenGLVGroup = None,
    run_time: float | None = None,
    rate_func: Callable[[float], float] = linear,
    lag_ratio: float = 0,
    **kwargs,
) -> None:
    arg_anim = flatten_iterable_parameters(animations)
    self.animations = [prepare_animation(anim) for anim in arg_anim]
    self.rate_func = rate_func
    self.group = group
    if self.group is None:
        mobjects = remove_list_redundancies(
            [anim.mobject for anim in self.animations if not anim.is_introducer()], # "Appending" to `self.group`
        )
        if config["renderer"] == RendererType.OPENGL:
            self.group = OpenGLGroup(*mobjects)
        else:
            self.group = Group(*mobjects)
    super().__init__(
        self.group, rate_func=self.rate_func, lag_ratio=lag_ratio, **kwargs
    )
    self.run_time: float = self.init_run_time(run_time)

Snippet 2

# Copied from Animation::__init__(self) @ animation.py
def __init__(
    self,
    mobject: Mobject | None,
    lag_ratio: float = DEFAULT_ANIMATION_LAG_RATIO,
    run_time: float = DEFAULT_ANIMATION_RUN_TIME,
    rate_func: Callable[[float], float] = smooth,
    reverse_rate_function: bool = False,
    name: str = None,
    remover: bool = False,  # remove a mobject from the screen?
    suspend_mobject_updating: bool = True,
    introducer: bool = False,
    *,
    _on_finish: Callable[[], None] = lambda _: None,
    **kwargs,
) -> None:
    self._typecheck_input(mobject)
    self.run_time: float = run_time
    self.rate_func: Callable[[float], float] = rate_func
    self.reverse_rate_function: bool = reverse_rate_function
    self.name: str | None = name
    self.remover: bool = remover
    self.introducer: bool = introducer
    self.suspend_mobject_updating: bool = suspend_mobject_updating
    self.lag_ratio: float = lag_ratio
    self._on_finish: Callable[[Scene], None] = _on_finish
    if config["renderer"] == RendererType.OPENGL:
        self.starting_mobject: OpenGLMobject = OpenGLMobject()
        self.mobject: OpenGLMobject = (
            mobject if mobject is not None else OpenGLMobject()
        )
    else:
        self.starting_mobject: Mobject = Mobject()
        self.mobject: Mobject = mobject if mobject is not None else Mobject()
    if kwargs:
        logger.debug("Animation received extra kwargs: %s", kwargs)

    if hasattr(self, "CONFIG"):
        logger.error(
            (
                "CONFIG has been removed from ManimCommunity.",
                "Please use keyword arguments instead.",
            ),
        )

Code Tested With

from manim import *

class laggingUpdater(Scene):
    def construct(self):
        vt = ValueTracker(0)
        dot1a = Dot().shift(3 * UP)

        def updater(mobj):
            mobj.set_x(vt.get_value())

        dot1a.add_updater(updater)

        self.play(
            LaggedStart(
                Create(dot1a),
            )
        )

        dot1a.resume_updating()

        self.wait()
        self.play(vt.animate.set_value(7), run_time=4)
        self.wait()

        self.play(vt.animate.set_value(0), run_time=2)
        self.wait()

laggingUpdater().render(preview=True)

Edit: removing newlines

JasonGrace2282 commented 3 weeks ago

Perhaps it's also a good idea for us to add a test for this in order to prevent future regressions.

chopan050 commented 1 week ago

TL;DR: if you could please modify finish() like this:

    def finish(self) -> None:
        for anim in self.animations:
            anim.finish()
        self.anims_begun[:] = True
        self.anims_finished[:] = True
        if self.suspend_mobject_updating:
            self.group.resume_updating()

that would be great!