[EXPERIMENTAL] Make play_logic and file_writer tests pass + Manager fixes (#4559)

* Fix embed dt calculation, Arrow stroke_width bug, and shader compatibility

* Restored lost fixes from b78ccba: Arrow stroke_width, Shaders AA, Manager interaction

* fix(core): resolve recursion in set_fill, ManimColor opacity error, and restore add_sound

* Fix(Experimental): Restore functionality and stabilize architecture

- Fix Arrow stroke_width initialization (float vs list bug) and restore scaling logic.
- Implement 'save_last_frame' logic by integrating it with skipping mechanism.
- Refactor Manager.post_construct to ensure correct exit and image saving behavior.
- Fix transparency issues in OpenGLRenderer (background_opacity and shader alpha).
- Update shader code to use modern 'texture()' syntax instead of 'texture2D()'.
- Fix VMobject recursion error in set_fill and ManimColor initialization.
- Restore audio connectivity in Scene.add_sound.
- Update tests to align with new Manager(Scene) architecture and document pending TODOs.
- Detailed rationale in CHANGES_SUMMARY.md.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Federico Burna 2026-02-13 11:16:40 -03:00 committed by GitHub
commit 8b5d48d424
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 107 additions and 59 deletions

View file

@ -676,7 +676,7 @@ class FileWriter(FileWriterProtocol):
output_container.mux(packet)
else:
output_stream = output_container.add_stream_from_template(
output_stream = output_container.add_stream(
template=partial_movies_stream,
)
if config.transparent and config.movie_file_extension == ".webm":
@ -768,12 +768,8 @@ class FileWriter(FileWriterProtocol):
output_container = av.open(
str(temp_file_path), mode="w", options=av_options
)
output_video_stream = output_container.add_stream_from_template(
template=video_stream
)
output_audio_stream = output_container.add_stream_from_template(
template=audio_stream
)
output_video_stream = output_container.add_stream(template=video_stream)
output_audio_stream = output_container.add_stream(template=audio_stream)
for packet in video_input.demux(video_stream):
# We need to skip the "flushing" packets that `demux` generates.

View file

@ -85,7 +85,7 @@ class Manager(Generic[SceneT]):
self._write_files = config.write_to_movie
# internal state
self._skipping = False
self._skipping = config.save_last_frame
def __str__(self) -> str:
return f"{self.__class__.__name__}({self.scene!r}) at time {self.time:.2f}s"
@ -113,7 +113,10 @@ class Manager(Generic[SceneT]):
-------
An instance of a renderer
"""
renderer = OpenGLRenderer()
renderer = OpenGLRenderer(
background_color=config.background_color,
background_opacity=config.background_opacity,
)
if config.preview:
renderer.use_window()
return renderer
@ -211,10 +214,13 @@ class Manager(Generic[SceneT]):
def post_construct(self) -> None:
"""Run post-construct hooks, and clean up the file writer."""
should_write_image = config.save_last_frame or (
config.write_to_movie and not self.file_writer.num_plays
)
if self.file_writer.num_plays:
self.file_writer.finish()
# otherwise no animations were played
elif config.write_to_movie or config.save_last_frame:
if should_write_image:
self.render_state(write_frame=False)
# FIXME: for some reason the OpenGLRenderer does not give out the
# correct frame values here
@ -240,10 +246,11 @@ class Manager(Generic[SceneT]):
"you can interact with the scene. "
"Press `command + q` or `esc` to quit"
)
# TODO: Replace with actual dt instead
# of hardcoded dt
dt = 1 / config.frame_rate
last_time = time.perf_counter()
while not self.window.is_closing:
current_time = time.perf_counter()
dt = current_time - last_time
last_time = current_time
self._update_frame(dt)
@contextlib.contextmanager
@ -266,7 +273,9 @@ class Manager(Generic[SceneT]):
# Animation Pipeline #
# ----------------------------------#
def _update_frame(self, dt: float, *, write_frame: bool | None = None) -> None:
def _update_frame(
self, dt: float, *, write_frame: bool | None = None, run_updaters: bool = True
) -> None:
"""Update the current frame by ``dt``
Parameters
@ -276,7 +285,8 @@ class Manager(Generic[SceneT]):
Default value checks :attr:`_write_files` to see if it should be written.
"""
self.time += dt
self.scene._update_mobjects(dt)
if run_updaters:
self.scene._update_mobjects(dt)
self.scene.time = self.time
if self.window is not None:
@ -400,10 +410,17 @@ class Manager(Generic[SceneT]):
progression.shape[0],
f"Animation %(num)d: {animations[0]}{', etc.' if len(animations) > 1 else ''}",
) as progress:
if self._skipping:
self.scene._update_animations(animations, run_time, run_time)
self._update_frame(run_time, run_updaters=False)
return
for t in progression:
dt, last_t = t - last_t, t
self.scene._update_animations(animations, t, dt)
self._update_frame(dt)
run_updaters = not self.scene.is_current_animation_frozen_frame(
animations
)
self._update_frame(dt, run_updaters=run_updaters)
for anim in animations:
if isinstance(anim, Wait) and anim.stop_condition:
if anim.stop_condition():

View file

@ -600,7 +600,7 @@ class Arrow(Line):
super().__init__(*args, buff=buff, stroke_width=stroke_width, **kwargs) # type: ignore[misc]
# TODO, should this be affected when
# Arrow.set_stroke is called?
self.initial_stroke_width = self.stroke_width
self.initial_stroke_width = stroke_width
self.add_tip(tip_shape=tip_shape)
self._set_stroke_width_from_length()

View file

@ -942,6 +942,22 @@ class Mobject:
"dt" in inspect.signature(updater).parameters for updater in self.updaters
)
@property
def has_updaters(self) -> bool:
"""Test if ``self`` has an updater.
Returns
-------
:class:`bool`
``True`` if at least one updater is added, ``False`` otherwise.
See Also
--------
:meth:`get_updaters`
"""
return len(self.updaters) > 0
def get_updaters(self) -> list[Updater]:
"""Return all updaters.

View file

@ -160,18 +160,18 @@ class VMobject(Mobject):
self.submobjects: list[VMobject]
# TODO: Find where color overwrites are happening and remove the color doubling
# if "color" in kwargs:
# fill_color = kwargs["color"]
# stroke_color = kwargs["color"]
if "color" in kwargs:
fill_color = kwargs["color"]
stroke_color = kwargs["color"]
if fill_color is not None:
self.fill_color = ManimColor.parse(fill_color)
if stroke_color is not None:
self.stroke_color = ManimColor.parse(stroke_color)
if fill_opacity is not None:
self.fill_color = self.fill_color.set_opacity(fill_opacity)
self.fill_color = self.fill_color.opacity(fill_opacity)
if stroke_opacity is not None:
self.stroke_color = self.stroke_color.set_opacity(stroke_opacity)
self.stroke_color = self.stroke_color.opacity(stroke_opacity)
def _assert_valid_submobjects(self, submobjects: Iterable[VMobject]) -> Self:
return self._assert_valid_submobjects_internal(submobjects, VMobject)
@ -320,11 +320,8 @@ class VMobject(Mobject):
if family:
for submobject in self.submobjects:
submobject.set_fill(color, opacity, family)
if color is not None:
self.fill_color = ManimColor.parse(color)
if opacity is not None:
self.fill_color = [c.opacity(opacity) for c in self.fill_color]
array_name = "fill_rgbas"
self.update_rgbas_array(array_name, color, opacity)
return self
def set_stroke(

View file

@ -167,7 +167,7 @@ class OpenGLRenderer(Renderer, RendererProtocol):
pixel_height if pixel_height is not None else config.pixel_height
)
self.samples = samples
if background_opacity:
if background_opacity is not None:
background_color = background_color.opacity(background_opacity)
self.background_color = background_color.to_rgba()
self.background_image = background_image

View file

@ -43,7 +43,7 @@ void main()
discard;
float previous_index =
texture2D(stencil_texture, vec2(gl_FragCoord.x / pixel_shape.x, gl_FragCoord.y / pixel_shape.y)).r;
texture(stencil_texture, vec2(gl_FragCoord.x / pixel_shape.x, gl_FragCoord.y / pixel_shape.y)).r;
// Check if we are behind another fill and if yes discard the current fragment
if (previous_index > index)

View file

@ -99,7 +99,7 @@ void main()
gl_FragDepth = gl_FragCoord.z;
// Get the previous index that was written to this fragment
float previous_index =
texture2D(stencil_texture, vec2(gl_FragCoord.x / pixel_shape.x, gl_FragCoord.y / pixel_shape.y)).r;
texture(stencil_texture, vec2(gl_FragCoord.x / pixel_shape.x, gl_FragCoord.y / pixel_shape.y)).r;
// If the index is the same that means we are overlapping with the fill and
// crossing through so we push the stroke forward a tiny bit
if (previous_index < index && previous_index != 0)

View file

@ -8,5 +8,5 @@ out vec4 frag_color;
void main()
{
frag_color = texture(tex, f_uv);
frag_color.a = 1.0;
// frag_color.a = 1.0;
}

View file

@ -42,7 +42,7 @@ if TYPE_CHECKING:
from manim.animation.protocol import AnimationProtocol
from manim.manager import Manager
from manim.typing import Point3D, Vector3D
from manim.typing import Point3D, StrPath, Vector3D
# TODO: these keybindings should be made configurable
@ -188,8 +188,10 @@ class Scene:
"""
# always rerender by returning True
# TODO: Apply caching here
return self.always_update_mobjects or any(
mob.has_updaters for mob in self.mobjects
return (
self.always_update_mobjects
or (len(self.updaters) > 0)
or any(mob.has_updaters for mob in self.mobjects)
)
def is_current_animation_frozen_frame(
@ -197,7 +199,18 @@ class Scene:
) -> bool:
if len(animations) == 0:
return False
return all(getattr(anim, "is_static_wait", False) for anim in animations)
# Check if all animations are frozen frames
any_frozen_frame = any(
getattr(anim, "is_static_wait", False) for anim in animations
)
all_frozen_frame = all(
getattr(anim, "is_static_wait", False) for anim in animations
)
if any_frozen_frame and not all_frozen_frame:
raise ValueError("All animations must be frozen frames to be frozen frames")
return all_frozen_frame
def has_time_based_updaters(self) -> bool:
return any(
@ -469,14 +482,17 @@ class Scene:
def add_sound(
self,
sound_file: str,
sound_file: StrPath,
time_offset: float = 0,
gain: float | None = None,
gain_to_background: float | None = None,
):
raise NotImplementedError("TODO")
if self.manager.file_writer is None:
return
time = self.time + time_offset
self.file_writer.add_sound(sound_file, time, gain, gain_to_background)
self.manager.file_writer.add_sound(
sound_file, time, gain, gain_to_background=gain_to_background
)
def get_state(self) -> SceneState:
return SceneState(self)

View file

@ -6,6 +6,7 @@ import av
import numpy as np
import pytest
import manim
from manim import DR, Circle, Create, Manager, Scene, Star
from manim.file_writer.file_writer import to_av_frame_rate
from manim.utils.commands import capture, get_video_metadata
@ -37,11 +38,12 @@ class StarScene(Scene):
def test_gif_writing(tmp_path, config, write_to_movie, transparent):
output_filename = f"gif_{'transparent' if transparent else 'opaque'}"
config.media_dir = tmp_path
config.quality = "low_quality"
config.format = "gif"
config.transparent = transparent
config.output_file = output_filename
Manager(StarScene).render()
with manim.tempconfig({"renderer": "opengl"}):
config.quality = "low_quality"
config.format = "gif"
config.transparent = transparent
config.output_file = output_filename
Manager(StarScene).render()
video_path = tmp_path / "videos" / "480p15" / f"{output_filename}.gif"
assert video_path.exists()
@ -97,13 +99,14 @@ def test_codecs(
codec,
pixel_format,
):
output_filename = f"codec_{format}_{'transparent' if transparent else 'opaque'}"
config.media_dir = tmp_path
config.quality = "low_quality"
config.format = format
config.transparent = transparent
config.output_file = output_filename
Manager(StarScene).render()
with manim.tempconfig({"renderer": "opengl"}):
output_filename = f"codec_{format}_{'transparent' if transparent else 'opaque'}"
config.media_dir = tmp_path
config.quality = "low_quality"
config.format = format
config.transparent = transparent
config.output_file = output_filename
Manager(StarScene).render()
video_path = tmp_path / "videos" / "480p15" / f"{output_filename}.{format}"
assert video_path.exists()

View file

@ -42,7 +42,7 @@ def test_t_values(config, using_temp_config, disabling_caching, frame_rate):
def test_t_values_with_skip_animations(using_temp_config, disabling_caching):
"""Test the behaviour of scene.skip_animations"""
manager = Manager(SquareToCircle)
manager._skip_animations = True
manager._skipping = True
scene = manager.scene
scene._update_animations = Mock()
manager.render()
@ -100,13 +100,16 @@ def test_wait_with_stop_condition(using_temp_config, disabling_caching):
def test_frozen_frame(using_temp_config, disabling_caching):
scene = Manager(SceneForFrozenFrameTests)
scene.render()
assert scene.mobject_update_count == 0
assert scene.scene_update_count == 0
manager = Manager(SceneForFrozenFrameTests)
manager.render()
assert manager.scene.mobject_update_count == 0
assert manager.scene.scene_update_count == 0
def test_t_values_with_cached_data(using_temp_config):
pytest.fail(
"TODO: Implement `_write_hashed_movie_file` and partial movie file logic in `Manager.py` to support caching. Currently, caching logic is stubbed out or incomplete."
)
"""Test the proper generation and use of the t values when an animation is cached."""
scene = SceneWithMultipleCalls()
# Mocking the file_writer will skip all the writing process.
@ -123,10 +126,10 @@ def test_t_values_with_cached_data(using_temp_config):
def test_t_values_save_last_frame(config, using_temp_config):
"""Test that there is only one t value handled when only saving the last frame"""
config.save_last_frame = True
scene = SquareToCircle()
scene.update_to_time = Mock()
scene.render()
scene.update_to_time.assert_called_once_with(1)
manager = Manager(SquareToCircle)
scene = manager.scene
manager.render()
assert scene.time == 1
def test_animate_with_changed_custom_attribute(using_temp_config):