diff --git a/docs/source/tutorials/configuration.rst b/docs/source/tutorials/configuration.rst index f6e9fb52b9..cbab162ba3 100644 --- a/docs/source/tutorials/configuration.rst +++ b/docs/source/tutorials/configuration.rst @@ -353,7 +353,8 @@ A list of all config options 'tex_template', 'tex_template_file', 'text_dir', 'top', 'transparent', 'upto_animation_number', 'use_opengl_renderer', 'use_webgl_renderer', 'verbosity', 'video_dir', 'webgl_renderer_path', 'window_position', - 'window_monitor', 'window_size', 'write_all', 'write_to_movie', 'enable_wireframe'] + 'window_monitor', 'window_size', 'write_all', 'write_to_movie', 'enable_wireframe', + 'force_window'] A list of all CLI flags diff --git a/manim/_config/default.cfg b/manim/_config/default.cfg index 1b555f9d77..9c3d4bf0b7 100644 --- a/manim/_config/default.cfg +++ b/manim/_config/default.cfg @@ -123,6 +123,9 @@ window_size = default # --window_monitor window_monitor = 0 +# --force_window +force_window = False + # --use_projection_fill_shaders use_projection_fill_shaders = False diff --git a/manim/_config/utils.py b/manim/_config/utils.py index 417b5f8828..bd5af10b09 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -297,6 +297,7 @@ class MyScene(Scene): "write_all", "write_to_movie", "zero_pad", + "force_window", } def __init__(self) -> None: @@ -541,6 +542,7 @@ def digest_parser(self, parser: configparser.ConfigParser) -> "ManimConfig": "use_projection_fill_shaders", "use_projection_stroke_shaders", "enable_wireframe", + "force_window", ]: setattr(self, key, parser["CLI"].getboolean(key, fallback=False)) @@ -690,6 +692,7 @@ def digest_args(self, args: argparse.Namespace) -> "ManimConfig": "use_projection_stroke_shaders", "zero_pad", "enable_wireframe", + "force_window", ]: if hasattr(args, key): attr = getattr(args, key) @@ -891,6 +894,12 @@ def log_to_file(self, val: str) -> None: doc="Enable wireframe debugging mode in opengl.", ) + force_window = property( + lambda self: self._d["force_window"], + lambda self, val: self._set_boolean("force_window", val), + doc="Set to force window when using the opengl renderer", + ) + @property def verbosity(self): """Logger verbosity; "DEBUG", "INFO", "WARNING", "ERROR", or "CRITICAL" (-v).""" @@ -1121,8 +1130,7 @@ def dry_run(self): self.write_to_movie is False and self.write_all is False and self.save_last_frame is False - and self.save_pngs is False - and self.save_as_gif is False + and not self.format ) @dry_run.setter @@ -1131,8 +1139,7 @@ def dry_run(self, val: bool) -> None: self.write_to_movie = False self.write_all = False self.save_last_frame = False - self.save_pngs = False - self.save_as_gif = False + self.format = None else: raise ValueError( "It is unclear what it means to set dry_run to " diff --git a/manim/cli/render/global_options.py b/manim/cli/render/global_options.py index a72eb001fc..520fcb17e3 100644 --- a/manim/cli/render/global_options.py +++ b/manim/cli/render/global_options.py @@ -84,4 +84,10 @@ def validate_gui_location(ctx, param, value): help="Enable wireframe debugging mode in opengl.", default=None, ), + option( + "--force_window", + is_flag=True, + help="Force window to open when using the opengl renderer, intended for debugging as it may impact performance", + default=False, + ), ) diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index 6acdbc1a03..24bb90c62a 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -5,7 +5,7 @@ import numpy as np from PIL import Image -from manim import config +from manim import config, logger from manim.renderer.cairo_renderer import handle_play_like_call from manim.utils.caching import handle_caching_play from manim.utils.color import color_to_rgba @@ -242,7 +242,7 @@ def init_scene(self, scene): ) self.scene = scene if not hasattr(self, "window"): - if config["preview"]: + if self.should_create_window(): from .opengl_renderer_window import Window self.window = Window(self) @@ -268,6 +268,20 @@ def init_scene(self, scene): moderngl.ONE, ) + def should_create_window(self): + if config["force_window"]: + logger.warning( + "'--force_window' is enabled, this is intended for debugging purposes " + "and may impact performance if used when outputting files", + ) + return True + return ( + config["preview"] + and not config["save_last_frame"] + and not config["format"] + and not config["write_to_movie"] + ) + def get_pixel_shape(self): if hasattr(self, "frame_buffer_object"): return self.frame_buffer_object.viewport[2:4] @@ -401,8 +415,7 @@ def render(self, scene, frame_offset, moving_mobjects): if self.skip_animations: return - if config["write_to_movie"]: - self.file_writer.write_frame(self) + self.file_writer.write_frame(self) if self.window is not None: self.window.swap_buffers() @@ -426,7 +439,11 @@ def update_frame(self, scene): self.animation_elapsed_time = time.time() - self.animation_start_time def scene_finished(self, scene): - if config["save_last_frame"]: + # When num_plays is 0, no images have been output, so output a single + # image in this case + if config["save_last_frame"] or ( + config["format"] == "png" and self.num_plays == 0 + ): self.update_frame(scene) self.file_writer.save_final_image(self.get_image()) self.file_writer.finish() @@ -444,10 +461,11 @@ def get_image(self) -> Image.Image: PIL.Image The PIL image of the array. """ + raw_buffer_data = self.get_raw_frame_buffer_object_data() image = Image.frombytes( "RGBA", self.get_pixel_shape(), - self.context.fbo.read(self.get_pixel_shape(), components=4), + raw_buffer_data, "raw", "RGBA", 0, diff --git a/manim/renderer/shader.py b/manim/renderer/shader.py index dd7205729d..49733fb3a1 100644 --- a/manim/renderer/shader.py +++ b/manim/renderer/shader.py @@ -354,7 +354,10 @@ def __init__( self.name = name # See if the program is cached. - if self.name in shader_program_cache: + if ( + self.name in shader_program_cache + and shader_program_cache[self.name].ctx == self.context + ): self.shader_program = shader_program_cache[self.name] elif source is not None: # Generate the shader from inline code if it was passed. diff --git a/manim/scene/scene.py b/manim/scene/scene.py index c942387267..99995bf6ce 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -1,9 +1,7 @@ """Basic canvas for animations.""" - __all__ = ["Scene"] - import copy import inspect import platform @@ -1006,11 +1004,33 @@ def play_internal(self, skip_rendering=False): # Closing the progress bar at the end of the play. self.time_progression.close() + def check_interactive_embed_is_valid(self): + if config["force_window"]: + return True + if self.skip_animation_preview: + logger.warning( + "Disabling interactive embed as 'skip_animation_preview' is enabled", + ) + return False + elif config["write_to_movie"]: + logger.warning("Disabling interactive embed as 'write_to_movie' is enabled") + return False + elif config["format"]: + logger.warning( + "Disabling interactive embed as '--format' is set as " + + config["format"], + ) + return False + elif not self.renderer.window: + logger.warning("Disabling interactive embed as no window was created") + return False + return True + def interactive_embed(self): """ Like embed(), but allows for screen interaction. """ - if self.skip_animation_preview or config["write_to_movie"]: + if not self.check_interactive_embed_is_valid(): return def ipython(shell, namespace): diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index a5de8af8df..5edc35eea6 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -286,25 +286,43 @@ def write_frame(self, frame_or_renderer): Pixel array of the frame. """ if config.renderer == "opengl": - renderer = frame_or_renderer - self.writing_process.stdin.write( - renderer.get_raw_frame_buffer_object_data(), - ) + self.write_opengl_frame(frame_or_renderer) else: frame = frame_or_renderer if write_to_movie(): self.writing_process.stdin.write(frame.tobytes()) if is_png_format() and not config["dry_run"]: - target_dir, extension = os.path.splitext(self.image_file_path) - if config["zero_pad"]: - Image.fromarray(frame).save( - f"{target_dir}{str(self.frame_count).zfill(config['zero_pad'])}{extension}", - ) - else: - Image.fromarray(frame).save( - f"{target_dir}{self.frame_count}{extension}", - ) - self.frame_count += 1 + self.output_image_from_array(frame) + + def write_opengl_frame(self, renderer): + if write_to_movie(): + self.writing_process.stdin.write( + renderer.get_raw_frame_buffer_object_data(), + ) + elif is_png_format() and not config["dry_run"]: + target_dir, extension = os.path.splitext(self.image_file_path) + self.output_image( + renderer.get_image(), + target_dir, + extension, + config["zero_pad"], + ) + + def output_image_from_array(self, frame_data): + target_dir, extension = os.path.splitext(self.image_file_path) + self.output_image( + Image.fromarray(frame_data), + target_dir, + extension, + config["zero_pad"], + ) + + def output_image(self, image: Image.Image, target_dir, ext, zero_pad: bool): + if zero_pad: + image.save(f"{target_dir}{str(self.frame_count).zfill(zero_pad)}{ext}") + else: + image.save(f"{target_dir}{self.frame_count}{ext}") + self.frame_count += 1 def save_final_image(self, image): """ diff --git a/pyproject.toml b/pyproject.toml index 2f37d0708a..3ff96ad70a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,7 @@ markers = "platform_python_implementation == 'CPython'" [tool.pytest.ini_options] markers = "slow: Mark the test as slow. Can be skipped with --skip_slow" -addopts = "--no-cov-on-fail --cov=manim --cov-report xml --cov-report term -n auto" +addopts = "--no-cov-on-fail --cov=manim --cov-report xml --cov-report term -n auto --dist=loadfile" [tool.isort] # from https://black.readthedocs.io/en/stable/compatible_configs.html diff --git a/tests/test_config.py b/tests/test_config.py index 607dff5976..e4cd1b3901 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -141,9 +141,7 @@ def test_temporary_dry_run(): def test_dry_run_with_png_format(): """Test that there are no exceptions when running a png without output""" - with tempconfig( - {"write_to_movie": False, "disable_caching": True, "format": "png"}, - ): + with tempconfig({"write_to_movie": False, "disable_caching": True}): assert config["dry_run"] is True scene = MyScene() scene.render() @@ -151,9 +149,7 @@ def test_dry_run_with_png_format(): def test_dry_run_with_png_format_skipped_animations(): """Test that there are no exceptions when running a png without output and skipped animations""" - with tempconfig( - {"write_to_movie": False, "disable_caching": True, "format": "png"}, - ): + with tempconfig({"write_to_movie": False, "disable_caching": True}): assert config["dry_run"] is True scene = MyScene(skip_animations=True) scene.render() diff --git a/tests/test_scene_rendering/conftest.py b/tests/test_scene_rendering/conftest.py index 8484e9868e..40b9f65815 100644 --- a/tests/test_scene_rendering/conftest.py +++ b/tests/test_scene_rendering/conftest.py @@ -25,6 +25,17 @@ def using_temp_config(tmpdir): yield +@pytest.fixture +def using_temp_opengl_config(tmpdir): + """Standard fixture that makes tests use a standard_config.cfg with a temp dir.""" + with tempconfig( + config.digest_file(Path(__file__).parent.parent / "standard_config.cfg"), + ): + config.media_dir = tmpdir + config.renderer = "opengl" + yield + + @pytest.fixture def disabling_caching(): with tempconfig({"disable_caching": True}): @@ -34,3 +45,15 @@ def disabling_caching(): @pytest.fixture def infallible_scenes_path(): return str(Path(__file__).parent / "infallible_scenes.py") + + +@pytest.fixture +def force_window_config_write_to_movie(): + with tempconfig({"force_window": True, "write_to_movie": True}): + yield + + +@pytest.fixture +def force_window_config_pngs(): + with tempconfig({"force_window": True, "format": "png"}): + yield diff --git a/tests/test_scene_rendering/test_cli_flags.py b/tests/test_scene_rendering/test_cli_flags.py index cf9194869b..14a041cea4 100644 --- a/tests/test_scene_rendering/test_cli_flags.py +++ b/tests/test_scene_rendering/test_cli_flags.py @@ -122,6 +122,32 @@ def test_s_flag(tmp_path, manim_cfg_file, simple_scenes_path): assert not is_empty, "running manim with -s flag did not render an image" +@pytest.mark.slow +def test_s_flag_opengl_renderer(tmp_path, manim_cfg_file, simple_scenes_path): + scene_name = "SquareToCircle" + command = [ + sys.executable, + "-m", + "manim", + "-ql", + "-s", + "--renderer", + "opengl", + "--media_dir", + str(tmp_path), + simple_scenes_path, + scene_name, + ] + out, err, exit_code = capture(command) + assert exit_code == 0, err + + exists = (tmp_path / "videos").exists() + assert not exists, "running manim with -s flag rendered a video" + + is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) + assert not is_empty, "running manim with -s flag did not render an image" + + @pytest.mark.slow def test_r_flag(tmp_path, manim_cfg_file, simple_scenes_path): scene_name = "SquareToCircle" @@ -370,6 +396,35 @@ def test_images_are_created_when_png_format_set( assert expected_png_path.exists(), "png file not found at " + str(expected_png_path) +@pytest.mark.slow +def test_images_are_created_when_png_format_set_for_opengl( + tmp_path, + manim_cfg_file, + simple_scenes_path, +): + """Test images are created in media directory when --format png is set for opengl""" + scene_name = "SquareToCircle" + command = [ + sys.executable, + "-m", + "manim", + "-ql", + "--renderer", + "opengl", + "--media_dir", + str(tmp_path), + "--format", + "png", + simple_scenes_path, + scene_name, + ] + out, err, exit_code = capture(command) + assert exit_code == 0, err + + expected_png_path = tmp_path / "images" / "simple_scenes" / "SquareToCircle0000.png" + assert expected_png_path.exists(), "png file not found at " + str(expected_png_path) + + @pytest.mark.slow def test_images_are_zero_padded_when_zero_pad_set( tmp_path, @@ -404,6 +459,42 @@ def test_images_are_zero_padded_when_zero_pad_set( assert expected_png_path.exists(), "png file not found at " + str(expected_png_path) +@pytest.mark.slow +def test_images_are_zero_padded_when_zero_pad_set_for_opengl( + tmp_path, + manim_cfg_file, + simple_scenes_path, +): + """Test images are zero padded when --format png and --zero_pad n are set with the opengl renderer""" + scene_name = "SquareToCircle" + command = [ + sys.executable, + "-m", + "manim", + "-ql", + "--renderer", + "opengl", + "--media_dir", + str(tmp_path), + "--format", + "png", + "--zero_pad", + "3", + simple_scenes_path, + scene_name, + ] + out, err, exit_code = capture(command) + assert exit_code == 0, err + + unexpected_png_path = tmp_path / "images" / "simple_scenes" / "SquareToCircle0.png" + assert not unexpected_png_path.exists(), "non zero padded png file found at " + str( + unexpected_png_path, + ) + + expected_png_path = tmp_path / "images" / "simple_scenes" / "SquareToCircle000.png" + assert expected_png_path.exists(), "png file not found at " + str(expected_png_path) + + @pytest.mark.slow def test_webm_format_output(tmp_path, manim_cfg_file, simple_scenes_path): """Test only webm created when --format webm is set""" diff --git a/tests/test_scene_rendering/test_opengl_renderer.py b/tests/test_scene_rendering/test_opengl_renderer.py new file mode 100644 index 0000000000..24d6379910 --- /dev/null +++ b/tests/test_scene_rendering/test_opengl_renderer.py @@ -0,0 +1,46 @@ +from unittest.mock import Mock + +import pytest + +from ..assert_utils import assert_file_exists +from .simple_scenes import * + + +def test_write_to_movie_disables_window(using_temp_opengl_config, disabling_caching): + """write_to_movie should disable window by default""" + scene = SquareToCircle() + renderer = scene.renderer + renderer.update_frame = Mock(wraps=renderer.update_frame) + scene.render() + assert renderer.window is None + assert_file_exists(config["output_file"]) + + +@pytest.mark.skip(msg="Temporarily skip due to failing in Windows CI") +def test_force_window_opengl_render_with_movies( + using_temp_opengl_config, + force_window_config_write_to_movie, + disabling_caching, +): + """force_window creates window when write_to_movie is set""" + scene = SquareToCircle() + renderer = scene.renderer + renderer.update_frame = Mock(wraps=renderer.update_frame) + scene.render() + assert renderer.window is not None + assert_file_exists(config["output_file"]) + renderer.window.close() + + +def test_force_window_opengl_render_with_format( + using_temp_opengl_config, + force_window_config_pngs, + disabling_caching, +): + """force_window creates window when format is set""" + scene = SquareToCircle() + renderer = scene.renderer + renderer.update_frame = Mock(wraps=renderer.update_frame) + scene.render() + assert renderer.window is not None + renderer.window.close()