From 49321fcdd9fd66d949484d8084bd191a66bff275 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 11:31:59 +0200 Subject: [PATCH 01/14] Normalize render output requests --- manim/_config/default.cfg | 29 +--- manim/_config/output.py | 139 ++++++++++++++++++ manim/_config/utils.py | 126 +++++----------- manim/cli/render/commands.py | 8 - manim/cli/render/output_options.py | 6 - manim/cli/render/render_options.py | 34 +++-- manim/manager.py | 8 +- manim/renderer/cairo_renderer.py | 20 +-- manim/renderer/opengl_renderer.py | 44 +++--- manim/scene/scene.py | 10 +- manim/scene/scene_file_writer.py | 115 ++++++++------- manim/utils/docbuild/manim_directive.py | 3 +- manim/utils/file_ops.py | 111 +------------- manim/utils/ipython_magic.py | 16 +- tests/helpers/graphical_units.py | 2 +- tests/module/test_manager.py | 13 ++ tests/opengl/test_config_opengl.py | 12 +- tests/test_config.py | 119 +++++++++++++-- tests/test_scene_rendering/conftest.py | 6 +- .../opengl/test_caching_related_opengl.py | 4 +- .../opengl/test_cli_flags_opengl.py | 14 +- .../opengl/test_opengl_renderer.py | 12 +- .../opengl/test_play_logic_opengl.py | 11 +- .../test_cairo_renderer.py | 7 +- tests/test_scene_rendering/test_cli_flags.py | 5 +- .../test_scene_rendering/test_file_writer.py | 8 +- .../test_parallel_encoding.py | 12 +- tests/test_scene_rendering/test_play_logic.py | 23 ++- 28 files changed, 500 insertions(+), 417 deletions(-) create mode 100644 manim/_config/output.py diff --git a/manim/_config/default.cfg b/manim/_config/default.cfg index a77e832279..1c8ee7219c 100644 --- a/manim/_config/default.cfg +++ b/manim/_config/default.cfg @@ -5,39 +5,22 @@ # specifying any flags [CLI] -# Each of the following will be set to True if the corresponding CLI flag -# is present when executing manim. If the flag is not present, they will -# be set to the value found here. For example, running manim with the -w -# flag will set WRITE_TO_MOVIE to True. However, since the default value -# of WRITE_TO_MOVIE defined in this file is also True, running manim -# without the -w value will also output a movie file. To change that, set -# WRITE_TO_MOVIE = False so that running manim without the -w flag will not -# generate a movie file. Note all of the following accept boolean values. +# CLI values override the settings found here. Optional CLI flags preserve +# configured values when they are not explicitly passed. # --notify_outdated_version notify_outdated_version = True -# -w, --write_to_movie -write_to_movie = True - -format = mp4 - -# -s, --save_last_frame -# setting save_last_frame to True forces write_to_movie to False -save_last_frame = False +# Primary output format. "auto" resolves to mp4 for opaque scenes and mov for +# transparent scenes. Use "none" to disable media output. +format = auto # -a, --write_all write_all = False -# -g, --save_pngs -save_pngs = False - # -0, --zero_pad zero_pad = 4 -# -i, --save_as_gif -save_as_gif = False - # --save_sections save_sections = False @@ -127,8 +110,6 @@ use_projection_fill_shaders = False # --use_projection_stroke_shaders use_projection_stroke_shaders = False -movie_file_extension = .mp4 - # Maximum number of partial movie files being encoded concurrently while the # scene continues rendering. 1 encodes each animation's file before the next # animation starts; values > 1 overlap encoding with rendering (bounds pipeline diff --git a/manim/_config/output.py b/manim/_config/output.py new file mode 100644 index 0000000000..a8dbf858c7 --- /dev/null +++ b/manim/_config/output.py @@ -0,0 +1,139 @@ +"""Resolved output configuration for one render session.""" + +from __future__ import annotations + +__all__ = ["OutputFormat", "OutputSpec", "resolve_output_spec"] + +from dataclasses import dataclass +from enum import StrEnum +from typing import Protocol + + +class OutputFormat(StrEnum): + """Supported primary render artifacts.""" + + NONE = "none" + AUTO = "auto" + MP4 = "mp4" + WEBM = "webm" + MOV = "mov" + GIF = "gif" + PNG = "png" + PNG_SEQUENCE = "png-sequence" + + @classmethod + def parse(cls, value: str | OutputFormat | None) -> OutputFormat: + """Normalize a compatibility config value.""" + if value is None or value == "": + return cls.AUTO + if isinstance(value, cls): + return value + if not isinstance(value, str): + raise ValueError(f"Invalid output format: {value!r}") + normalized = value.lower().replace("_", "-") + return cls(normalized) + + +_VIDEO_FORMATS = frozenset( + {OutputFormat.MP4, OutputFormat.WEBM, OutputFormat.MOV, OutputFormat.GIF} +) + + +@dataclass(frozen=True, slots=True) +class OutputSpec: + """Immutable, validated output intent for one render session. + + ``format`` is concrete: ``AUTO`` is resolved before this object is created. + The extension of cached video segments is deliberately separate from the + extension of the final artifact; GIF output, for example, uses encoded video + segments before final GIF assembly. + """ + + format: OutputFormat + transparent: bool + save_sections: bool + + def __post_init__(self) -> None: + if self.format is OutputFormat.AUTO: + raise ValueError("OutputSpec requires a concrete output format.") + if self.transparent and self.format is OutputFormat.MP4: + raise ValueError( + "MP4 output does not support an alpha channel. Use --format=mov " + "or --format=webm for transparent video.", + ) + if self.save_sections and not self.is_video: + raise ValueError("Section output requires a video output format.") + + @property + def enabled(self) -> bool: + """Whether this session produces a primary media artifact.""" + return self.format is not OutputFormat.NONE + + @property + def is_video(self) -> bool: + """Whether the primary artifact is time-based video.""" + return self.format in _VIDEO_FORMATS + + @property + def is_still(self) -> bool: + """Whether only the evaluated final scene state is written as PNG.""" + return self.format is OutputFormat.PNG + + @property + def is_image_sequence(self) -> bool: + """Whether every rendered frame is written as a PNG image.""" + return self.format is OutputFormat.PNG_SEQUENCE + + @property + def is_gif(self) -> bool: + return self.format is OutputFormat.GIF + + @property + def artifact_extension(self) -> str | None: + """Extension of the requested primary artifact.""" + if self.format is OutputFormat.NONE: + return None + if self.format in {OutputFormat.PNG, OutputFormat.PNG_SEQUENCE}: + return ".png" + return f".{self.format.value}" + + @property + def segment_extension(self) -> str: + """Container extension used for cached rendered video segments.""" + if not self.is_video: + raise ValueError("Non-video output does not use video segments.") + if self.format is OutputFormat.GIF: + return ".mov" if self.transparent else ".mp4" + extension = self.artifact_extension + assert extension is not None + return extension + + +class _OutputConfigSource(Protocol): + format: str | OutputFormat | None + save_last_frame: bool + save_sections: bool + transparent: bool + dry_run: bool + + +def resolve_output_spec(config: _OutputConfigSource) -> OutputSpec: + """Resolve mutable compatibility configuration into immutable output intent.""" + if config.dry_run: + return OutputSpec( + format=OutputFormat.NONE, + transparent=config.transparent, + save_sections=False, + ) + + requested = OutputFormat.parse(config.format) + if config.save_last_frame: + requested = OutputFormat.PNG + elif requested is OutputFormat.AUTO: + requested = OutputFormat.MOV if config.transparent else OutputFormat.MP4 + + return OutputSpec( + format=requested, + transparent=config.transparent, + save_sections=config.save_sections, + ) diff --git a/manim/_config/utils.py b/manim/_config/utils.py index 8d1de7a9d4..26f785cc78 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -27,6 +27,7 @@ import numpy as np from manim import constants +from manim._config.output import OutputFormat from manim.constants import RendererType from manim.utils.color import ManimColor from manim.utils.tex import TexTemplate @@ -286,7 +287,6 @@ class MyScene(Scene): ... "max_files_cached", "max_inflight_encoders", "media_dir", - "movie_file_extension", "notify_outdated_version", "output_file", "partial_movie_dir", @@ -296,10 +296,7 @@ class MyScene(Scene): ... "preview", "progress_bar", "quality", - "save_as_gif", "save_sections", - "save_last_frame", - "save_pngs", "scene_names", "seed", "show_in_file_browser", @@ -321,7 +318,6 @@ class MyScene(Scene): ... "window_size", "window_monitor", "write_all", - "write_to_movie", "zero_pad", "force_window", "no_latex_cleanup", @@ -580,14 +576,12 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: """ self._parser = parser + self.format = parser["CLI"].get("format", fallback="auto", raw=True) + # boolean keys for key in [ "notify_outdated_version", - "write_to_movie", - "save_last_frame", "write_all", - "save_pngs", - "save_as_gif", "save_sections", "preview", "show_in_file_browser", @@ -637,7 +631,6 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: "partial_movie_dir", "input_file", "output_file", - "movie_file_extension", "background_color", "renderer", "window_position", @@ -755,16 +748,14 @@ def digest_args(self, args: argparse.Namespace) -> Self: self.input_file = Path(args.file).absolute() self.scene_names = args.scene_names if args.scene_names is not None else [] - self.output_file = args.output_file + if args.output_file is not None: + self.output_file = args.output_file for key in [ "notify_outdated_version", "preview", "show_in_file_browser", - "write_to_movie", "save_last_frame", - "save_pngs", - "save_as_gif", "save_sections", "write_all", "disable_caching", @@ -809,9 +800,6 @@ def digest_args(self, args: argparse.Namespace) -> Self: if attr is not None: self[key] = attr - if self["save_last_frame"]: - self["write_to_movie"] = False - # Handle the -n flag. nflag = args.from_animation_number if nflag: @@ -857,9 +845,14 @@ def digest_args(self, args: argparse.Namespace) -> Self: if args.tex_template: self.tex_template = TexTemplate.from_file(args.tex_template) - if self.renderer == RendererType.OPENGL and args.write_to_movie is None: - # --write_to_movie was not passed on the command line, so don't generate video. - self["write_to_movie"] = False + # Preserve OpenGL's existing opt-in file-output behavior until live + # preview and post-render preview become separate session requests. + if ( + self.renderer == RendererType.OPENGL + and args.format is None + and OutputFormat.parse(self.format) is OutputFormat.AUTO + ): + self.format = OutputFormat.NONE # Handle --gui_location flag. if args.gui_location is not None: @@ -954,23 +947,19 @@ def notify_outdated_version(self) -> bool: def notify_outdated_version(self, value: bool) -> None: self._set_boolean("notify_outdated_version", value) - @property - def write_to_movie(self) -> bool: - """Whether to render the scene to a movie file (-w).""" - return self._d["write_to_movie"] - - @write_to_movie.setter - def write_to_movie(self, value: bool) -> None: - self._set_boolean("write_to_movie", value) - @property def save_last_frame(self) -> bool: - """Whether to save the last frame of the scene as an image file (-s).""" - return self._d["save_last_frame"] + """Whether to use final-state-only PNG output (-s).""" + return OutputFormat.parse(self.format) is OutputFormat.PNG @save_last_frame.setter def save_last_frame(self, value: bool) -> None: - self._set_boolean("save_last_frame", value) + if not isinstance(value, bool): + raise ValueError("save_last_frame must be boolean") + if value: + self.format = OutputFormat.PNG + elif self.save_last_frame: + self.format = OutputFormat.AUTO @property def write_all(self) -> bool: @@ -981,24 +970,6 @@ def write_all(self) -> bool: def write_all(self, value: bool) -> None: self._set_boolean("write_all", value) - @property - def save_pngs(self) -> bool: - """Whether to save all frames in the scene as images files (-g).""" - return self._d["save_pngs"] - - @save_pngs.setter - def save_pngs(self, value: bool) -> None: - self._set_boolean("save_pngs", value) - - @property - def save_as_gif(self) -> bool: - """Whether to save the rendered scene in .gif format (-i).""" - return self._d["save_as_gif"] - - @save_as_gif.setter - def save_as_gif(self, value: bool) -> None: - self._set_boolean("save_as_gif", value) - @property def save_sections(self) -> bool: """Whether to save single videos for each section in addition to the movie file.""" @@ -1059,18 +1030,20 @@ def verbosity(self, val: str) -> None: @property def format(self) -> str | None: - """File format; "png", "gif", "mp4", "webm" or "mov".""" + """Primary output format. + + ``png`` writes only the evaluated final scene state; + ``png-sequence`` writes every rendered frame. ``auto`` selects MP4 for + opaque output and MOV for transparent output, while ``none`` disables + media output. + """ return self._d["format"] @format.setter # noqa: A003 - def format(self, val: str) -> None: - self._set_from_list( - "format", - val, - [None, "png", "gif", "mp4", "mov", "webm"], - ) - self.resolve_movie_file_extension(self.transparent) - if self.format == "webm": + def format(self, val: str | OutputFormat | None) -> None: + output_format = OutputFormat.parse(val) + self._d["format"] = output_format.value + if output_format is OutputFormat.WEBM: logger.warning( "Output format set as webm, this can be slower than other formats", ) @@ -1305,15 +1278,6 @@ def disable_caching_warning(self) -> bool: def disable_caching_warning(self, value: bool) -> None: self._set_boolean("disable_caching_warning", value) - @property - def movie_file_extension(self) -> str: - """Either .mp4, .webm or .mov.""" - return self._d["movie_file_extension"] - - @movie_file_extension.setter - def movie_file_extension(self, value: str) -> None: - self._set_from_list("movie_file_extension", value, [".mp4", ".mov", ".webm"]) - @property def background_opacity(self) -> float: """A number between 0.0 (fully transparent) and 1.0 (fully opaque).""" @@ -1322,8 +1286,6 @@ def background_opacity(self) -> float: @background_opacity.setter def background_opacity(self, value: float) -> None: self._set_between("background_opacity", value, 0, 1) - if self.background_opacity < 1: - self.resolve_movie_file_extension(is_transparent=True) @property def frame_size(self) -> tuple[int, int]: @@ -1365,7 +1327,6 @@ def transparent(self) -> bool: @transparent.setter def transparent(self, value: bool) -> None: self._d["background_opacity"] = float(not value) - self.resolve_movie_file_extension(value) @property def dry_run(self) -> bool: @@ -1374,12 +1335,7 @@ def dry_run(self) -> bool: @dry_run.setter def dry_run(self, val: bool) -> None: - self._d["dry_run"] = val - if val: - self.write_to_movie = False - self.write_all = False - self.save_last_frame = False - self.format = None + self._set_boolean("dry_run", val) @property def renderer(self) -> RendererType: @@ -1477,22 +1433,6 @@ def window_size(self) -> str | tuple[int, ...]: def window_size(self, value: str | tuple[int, ...]) -> None: self._d.__setitem__("window_size", value) - def resolve_movie_file_extension(self, is_transparent: bool) -> None: - prev_file_extension = self.movie_file_extension - if is_transparent: - self.movie_file_extension = ".webm" if self.format == "webm" else ".mov" - elif self.format == "webm": - self.movie_file_extension = ".webm" - elif self.format == "mov": - self.movie_file_extension = ".mov" - else: - self.movie_file_extension = ".mp4" - if self.movie_file_extension != prev_file_extension: - logger.warning( - f"Output format changed to '{self.movie_file_extension}' " - "to support transparency", - ) - @property def enable_gui(self) -> bool: """Enable GUI interaction.""" diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index 5c36b41a94..5a11929bc5 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -76,14 +76,6 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: SCENES is an optional list of scenes in the file. """ - if kwargs["save_as_gif"]: - logger.warning("--save_as_gif is deprecated, please use --format=gif instead!") - kwargs["format"] = "gif" - - if kwargs["save_pngs"]: - logger.warning("--save_pngs is deprecated, please use --format=png instead!") - kwargs["format"] = "png" - if kwargs["show_in_file_browser"]: logger.warning( "The short form of show_in_file_browser is deprecated and will be moved to support --format.", diff --git a/manim/cli/render/output_options.py b/manim/cli/render/output_options.py index a7613f1565..7c2bb7095f 100644 --- a/manim/cli/render/output_options.py +++ b/manim/cli/render/output_options.py @@ -20,12 +20,6 @@ default=None, help="Zero padding for PNG file names.", ), - option( - "--write_to_movie", - is_flag=True, - default=None, - help="Write the video rendered with opengl to a file.", - ), option( "--media_dir", type=Path(), diff --git a/manim/cli/render/render_options.py b/manim/cli/render/render_options.py index 4e42c134b8..31d6c2eba8 100644 --- a/manim/cli/render/render_options.py +++ b/manim/cli/render/render_options.py @@ -121,15 +121,30 @@ def validate_resolution( ), option( "--format", - type=Choice(["png", "gif", "mp4", "webm", "mov"], case_sensitive=False), + type=Choice( + [ + "auto", + "none", + "png", + "png-sequence", + "gif", + "mp4", + "webm", + "mov", + ], + case_sensitive=False, + ), default=None, + help="Primary output format. PNG renders only the final scene state; " + "png-sequence writes every rendered frame.", ), option( "-s", "--save_last_frame", default=None, is_flag=True, - help="Render and save only the last frame of a scene as a PNG image.", + help="Fast-forward animations and save the final scene state as PNG " + "(equivalent to --format=png).", ), option( "-q", @@ -189,21 +204,7 @@ def validate_resolution( case_sensitive=False, ), help="Select a renderer for your Scene.", - default="cairo", - ), - option( - "-g", - "--save_pngs", - is_flag=True, default=None, - help="Save each frame as png (Deprecated).", - ), - option( - "-i", - "--save_as_gif", - default=None, - is_flag=True, - help="Save as a gif (Deprecated).", ), option( "--save_sections", @@ -215,6 +216,7 @@ def validate_resolution( "-t", "--transparent", is_flag=True, + default=None, help="Render scenes with alpha channel.", ), option( diff --git a/manim/manager.py b/manim/manager.py index dae957e53b..b8b4b7e2d4 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -13,6 +13,7 @@ from .utils.file_ops import open_media_file if TYPE_CHECKING: + from ._config.output import OutputSpec from .animation.animation import Animation from .camera.camera import Camera from .mobject.mobject import Mobject, _AnimationBuilder @@ -85,6 +86,11 @@ def file_writer(self) -> SceneFileWriter: """Return the current renderer's file writer.""" return cast("SceneFileWriter", self.renderer.file_writer) + @property + def output_spec(self) -> OutputSpec: + """Return the immutable output intent captured for this session.""" + return self.file_writer.output_spec + @property def time(self) -> float: """Return the current renderer time.""" @@ -186,7 +192,7 @@ def post_construct(self) -> None: self.renderer.scene_finished(self.scene) # Show info only if animations are rendered or to get image. - if self.num_plays or config["format"] == "png" or config["save_last_frame"]: + if self.num_plays or self.output_spec.enabled: logger.info( f"Rendered {str(self.scene)}\nPlayed {self.num_plays} animations", ) diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index da7cab133d..271d196b0c 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -252,7 +252,7 @@ def update_skipping_status(self) -> None: # there is always at least one section -> no out of bounds here if self.file_writer.sections[-1].skip_animations: self.skip_animations = True - if config["save_last_frame"]: + if self.file_writer.output_spec.is_still: self.skip_animations = True if ( config.from_animation_number > 0 @@ -267,17 +267,17 @@ def update_skipping_status(self) -> None: raise EndSceneEarlyException() def scene_finished(self, scene: Scene) -> None: - # If no animations in scene, render an image instead - if self.num_plays: + output = self.file_writer.output_spec + if self.num_plays and (output.is_video or output.is_image_sequence): self.file_writer.finish() - elif config.write_to_movie: - config.save_last_frame = True - config.write_to_movie = False - else: + elif not self.num_plays: self.static_image = None self.update_frame(scene) - if config["save_last_frame"]: - self.static_image = None - self.update_frame(scene) + # A video request for a scene with no plays retains the established + # behavior of producing a useful still image instead of an empty movie. + if output.is_still or (not self.num_plays and output.is_video): + if self.num_plays: + self.static_image = None + self.update_frame(scene) self.file_writer.save_image(self.camera.get_image()) diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index 80fc251f06..e85e04af49 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -581,11 +581,11 @@ def should_create_window(self) -> bool: "and may impact performance if used when outputting files", ) return True + output = self.file_writer.output_spec return ( config["preview"] - and not config["save_last_frame"] - and not config["format"] - and not config["write_to_movie"] + and not output.is_still + and not output.enabled and not config["dry_run"] ) @@ -802,6 +802,8 @@ def update_skipping_status(self) -> None: # there is always at least one section -> no out of bounds here if self.file_writer.sections[-1].skip_animations: self.skip_animations = True + if self.file_writer.output_spec.is_still: + self.skip_animations = True if ( config.from_animation_number > 0 and self.num_plays < config.from_animation_number @@ -956,31 +958,18 @@ def update_frame(self, scene: Scene) -> None: self.animation_elapsed_time = time.time() - self.animation_start_time def scene_finished(self, scene: Scene) -> None: - """ - Handle the finalization process after a scene has finished rendering. - - Performs the following actions: - - If any plays (animations) have occurred, finalizes the file writing process. - - If no plays have occurred but movie writing is enabled, disables - movie writing to avoid creating an empty movie file. - - If the configuration requires saving the last frame, - updates and saves the final image of the scene. - - Parameters - ---------- - scene : Scene - The scene that has finished rendering. - """ - # When num_plays is 0, no images have been output, so output a single - # image in this case - if self.num_plays > 0: + """Finalize configured output for the scene.""" + output = self.file_writer.output_spec + if self.num_plays > 0 and (output.is_video or output.is_image_sequence): self.file_writer.finish() - elif self.num_plays == 0 and config.write_to_movie: - config.write_to_movie = False + elif self.num_plays == 0: + # Keep the framebuffer useful for direct renderer access and + # graphical tests even when no media artifact was requested. + self.update_frame(scene) if self.should_save_last_frame(): - config.save_last_frame = True - self.update_frame(scene) + if self.num_plays > 0: + self.update_frame(scene) self.file_writer.save_image(self.get_image()) def should_save_last_frame(self) -> bool: @@ -991,11 +980,12 @@ def should_save_last_frame(self) -> bool: - The scene is not in interactive mode. - This is the first play (i.e., num_plays == 0). """ - if config["save_last_frame"]: + output = self.file_writer.output_spec + if output.is_still: return True if self.scene.interactive_mode: return False - return self.num_plays == 0 + return self.num_plays == 0 and output.is_video def get_image(self) -> Image.Image: """ diff --git a/manim/scene/scene.py b/manim/scene/scene.py index b4ef54f38f..6ebc2725c0 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -1369,13 +1369,9 @@ def check_interactive_embed_is_valid(self) -> bool: "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"]: + elif self.renderer.file_writer.output_spec.enabled: logger.warning( - "Disabling interactive embed as '--format' is set as " - + config["format"], + "Disabling interactive embed while media output is enabled", ) return False elif not self.renderer.window: @@ -1554,7 +1550,7 @@ def embed(self) -> None: if not config["preview"]: logger.warning("Called embed() while no preview window is available.") return - if config["write_to_movie"]: + if self.renderer.file_writer.output_spec.enabled: logger.warning("embed() is skipped while writing to a file.") return diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index 17661c23aa..f844615838 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -35,15 +35,13 @@ from .. import config, logger from .._config.logger_utils import set_file_logger +from .._config.output import OutputSpec, resolve_output_spec from ..constants import RendererType from ..utils.file_ops import ( add_extension_if_not_present, add_version_before_extension, guarantee_existence, - is_gif_format, - is_png_format, modify_atime, - write_to_movie, ) from ..utils.sounds import get_full_sound_file_path from .section import DefaultSectionType, Section @@ -214,11 +212,9 @@ class SceneFileWriter: name of movie without extension and basis for section video names Some useful attributes are: - "write_to_movie" (bool=False) - Whether or not to write the animations into a video file. - "movie_file_extension" (str=".mp4") - The file-type extension of the outputted video. - "partial_movie_files" + ``output_spec`` + The immutable primary output intent for this render session. + ``partial_movie_files`` List of all the partial-movie files. """ @@ -232,6 +228,7 @@ def __init__( **kwargs: Any, ) -> None: self.renderer = renderer + self.output_spec: OutputSpec = resolve_output_spec(config) self._inflight_encode_jobs: list[_PartialMovieEncodeJob] = [] self._inflight_by_path: dict[str, _PartialMovieEncodeJob] = {} self._current_encode_job: _PartialMovieEncodeJob | None = None @@ -257,7 +254,7 @@ def init_output_directories(self, scene_name: str) -> None: exist, they will be created. """ - if config["dry_run"]: # in dry-run mode there is no output + if not self.output_spec.enabled: return module_name = config.get_dir("input_file").stem if config["input_file"] else "" @@ -278,27 +275,31 @@ def init_output_directories(self, scene_name: str) -> None: self.image_file_path = image_dir / add_extension_if_not_present( self.output_name, ".png" ) + if self.output_spec.is_image_sequence: + self.image_sequence_directory = guarantee_existence( + self.image_file_path.with_suffix(""), + ) - if write_to_movie(): + if self.output_spec.is_video: movie_dir = guarantee_existence( config.get_dir( "video_dir", module_name=module_name, scene_name=scene_name ), ) self.movie_file_path = movie_dir / add_extension_if_not_present( - self.output_name, config["movie_file_extension"] + self.output_name, self.output_spec.segment_extension ) # TODO: /dev/null would be good in case sections_output_dir is used without being set (doesn't work on Windows), everyone likes defensive programming, right? self.sections_output_dir = Path("") - if config.save_sections: + if self.output_spec.save_sections: self.sections_output_dir = guarantee_existence( config.get_dir( "sections_dir", module_name=module_name, scene_name=scene_name ) ) - if is_gif_format(): + if self.output_spec.is_gif: self.gif_file_path = add_extension_if_not_present( self.output_name, ".gif" ) @@ -336,14 +337,9 @@ def next_section(self, name: str, type_: str, skip_animations: bool) -> None: # images don't support sections section_video: str | None = None # don't save when None - if ( - not config.dry_run - and write_to_movie() - and config.save_sections - and not skip_animations - ): + if self.output_spec.save_sections and not skip_animations: # relative to index file - section_video = f"{self.output_name}_{len(self.sections):04}_{name}{config.movie_file_extension}" + section_video = f"{self.output_name}_{len(self.sections):04}_{name}{self.output_spec.segment_extension}" self.sections.append( Section( @@ -366,7 +362,10 @@ def add_partial_movie_file(self, hash_animation: str | None) -> None: hash_animation Hash of the animation. """ - if not hasattr(self, "partial_movie_directory") or not write_to_movie(): + if ( + not hasattr(self, "partial_movie_directory") + or not self.output_spec.is_video + ): return # None has to be added to partial_movie_files to keep the right index with scene.num_plays. @@ -377,7 +376,7 @@ def add_partial_movie_file(self, hash_animation: str | None) -> None: else: new_partial_movie_file = str( self.partial_movie_directory - / f"{hash_animation}{config['movie_file_extension']}" + / f"{hash_animation}{self.output_spec.segment_extension}" ) self.partial_movie_files.append(new_partial_movie_file) self.sections[-1].partial_movie_files.append(new_partial_movie_file) @@ -520,7 +519,7 @@ def begin_animation( allow_write Whether or not to write to a video file. """ - if write_to_movie() and allow_write: + if self.output_spec.is_video and allow_write: self.open_partial_movie_stream(file_path=file_path) def end_animation(self, allow_write: bool = False) -> None: @@ -531,7 +530,7 @@ def end_animation(self, allow_write: bool = False) -> None: allow_write Whether or not to write to a video file. """ - if write_to_movie() and allow_write: + if self.output_spec.is_video and allow_write: self.close_partial_movie_stream() def write_frame( @@ -546,7 +545,7 @@ def write_frame( num_frames The number of times to write frame. """ - if write_to_movie(): + if self.output_spec.is_video: if isinstance(frame_or_renderer, np.ndarray): frame = frame_or_renderer else: @@ -569,7 +568,7 @@ def write_frame( job.join() job.put(num_frames, frame) - if is_png_format() and not config["dry_run"]: + if self.output_spec.is_image_sequence: if isinstance(frame_or_renderer, np.ndarray): image = Image.fromarray(frame_or_renderer) else: @@ -578,7 +577,7 @@ def write_frame( if config.renderer == RendererType.OPENGL else Image.fromarray(frame_or_renderer) ) - target_dir = self.image_file_path.parent / self.image_file_path.stem + target_dir = self.image_sequence_directory extension = self.image_file_path.suffix self.output_image( image, @@ -590,10 +589,8 @@ def write_frame( def output_image( self, image: Image.Image, target_dir: StrPath, ext: str, zero_pad: int ) -> None: - 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}") + file_name = f"{self.frame_count:0{zero_pad}d}{ext}" + image.save(Path(target_dir) / file_name) self.frame_count += 1 def save_image(self, image: Image.Image) -> None: @@ -604,7 +601,7 @@ def save_image(self, image: Image.Image) -> None: image The pixel array of the image to save. """ - if config["dry_run"]: + if not self.output_spec.enabled: return if not config["output_file"]: self.image_file_path = add_version_before_extension(self.image_file_path) @@ -617,18 +614,19 @@ def finish(self) -> None: Combines the partial movie files into the whole scene. If save_last_frame is True, saves the last frame in the default image directory. """ - if write_to_movie(): + if self.output_spec.is_video: self.join_all_encode_jobs() self.combine_to_movie() - if config.save_sections: + if self.output_spec.save_sections: self.combine_to_section_videos() # Cache cleanup runs after the in-flight encode jobs have been drained. if config["flush_cache"]: self.flush_cache_directory() else: self.clean_cache() - elif is_png_format() and not config["dry_run"]: - target_dir = self.image_file_path.parent / self.image_file_path.stem + elif self.output_spec.is_image_sequence: + target_dir = self.image_sequence_directory + self.final_file_path = target_dir logger.info("\n%i images ready at %s\n", self.frame_count, str(target_dir)) if self.subcaptions: self.write_subcaption_file() @@ -660,13 +658,13 @@ def open_partial_movie_stream(self, file_path: StrPath | None = None) -> None: "crf": "23", # ffmpeg: -crf, constant rate factor (improved bitrate) } - if config.movie_file_extension == ".webm": + if self.output_spec.segment_extension == ".webm": partial_movie_file_codec = "libvpx-vp9" av_options["-auto-alt-ref"] = "1" - if config.transparent: + if self.output_spec.transparent: partial_movie_file_pix_fmt = "yuva420p" - elif config.transparent: + elif self.output_spec.transparent: partial_movie_file_codec = "qtrle" partial_movie_file_pix_fmt = "argb" @@ -812,11 +810,14 @@ def is_already_cached(self, hash_invocation: str) -> bool: :class:`bool` Whether the file exists. """ - if not hasattr(self, "partial_movie_directory") or not write_to_movie(): + if ( + not hasattr(self, "partial_movie_directory") + or not self.output_spec.is_video + ): return False path = ( self.partial_movie_directory - / f"{hash_invocation}{config['movie_file_extension']}" + / f"{hash_invocation}{self.output_spec.segment_extension}" ) path_key = str(path) if path_key in self._inflight_by_path: @@ -866,7 +867,7 @@ def combine_files( codec_name="gif", ) output_stream.pix_fmt = "rgb8" - if config.transparent: + if self.output_spec.transparent: output_stream.pix_fmt = "pal8" output_stream.width = config.pixel_width output_stream.height = config.pixel_height @@ -912,7 +913,10 @@ def combine_files( output_stream = output_container.add_stream_from_template( template=partial_movies_stream, ) - if config.transparent and config.movie_file_extension == ".webm": + if ( + self.output_spec.transparent + and self.output_spec.segment_extension == ".webm" + ): output_stream.pix_fmt = "yuva420p" for packet in partial_movies_input.demux(partial_movies_stream): # We need to skip the "flushing" packets that `demux` generates. @@ -942,7 +946,7 @@ def combine_to_movie(self) -> None: # determine output path movie_file_path = self.movie_file_path - if is_gif_format(): + if self.output_spec.is_gif: movie_file_path = self.gif_file_path if len(partial_movie_files) == 0: # Prevent calling concat on empty list @@ -953,12 +957,12 @@ def combine_to_movie(self) -> None: self.combine_files( partial_movie_files, movie_file_path, - is_gif_format(), + self.output_spec.is_gif, self.includes_sound, ) # handle sound - if self.includes_sound and config.format != "gif": + if self.includes_sound and not self.output_spec.is_gif: sound_file_path = movie_file_path.with_suffix(".wav") # Makes sure sound file length will match video file self.add_audio_segment(AudioSegment.silent(0)) @@ -973,11 +977,11 @@ def combine_to_movie(self) -> None: # but tries to call ffmpeg via its CLI -- which we want # to avoid. This is why we need to do the conversion # manually. - if config.movie_file_extension == ".webm": + if self.output_spec.segment_extension == ".webm": ogg_sound_file_path = sound_file_path.with_suffix(".ogg") convert_audio(sound_file_path, ogg_sound_file_path, "libvorbis") sound_file_path = ogg_sound_file_path - elif config.movie_file_extension == ".mp4": + elif self.output_spec.segment_extension == ".mp4": # Similarly, pyav may reject wav audio in an .mp4 file; # convert to AAC. aac_sound_file_path = sound_file_path.with_suffix(".aac") @@ -1032,7 +1036,7 @@ def combine_to_movie(self) -> None: sound_file_path.unlink() self.print_file_ready_message(str(movie_file_path)) - if write_to_movie(): + if self.output_spec.is_video: for file_path in partial_movie_files: # We have to modify the accessed time so if we have to clean the cache we remove the one used the longest. modify_atime(file_path) @@ -1109,14 +1113,17 @@ def flush_cache_directory(self) -> None: ) def write_subcaption_file(self) -> None: - """Writes the subcaption file.""" - if config.output_file is None: + """Writes the subcaption file next to the primary video artifact.""" + if not self.output_spec.is_video: return - subcaption_file = Path(config.output_file).with_suffix(".srt") + media_path = ( + self.gif_file_path if self.output_spec.is_gif else self.movie_file_path + ) + subcaption_file = Path(media_path).with_suffix(".srt") subcaption_file.write_text(srt.compose(self.subcaptions), encoding="utf-8") logger.info(f"Subcaption file has been written as {subcaption_file}") def print_file_ready_message(self, file_path: StrPath) -> None: - """Prints the "File Ready" message to STDOUT.""" - config["output_file"] = file_path + """Record and report a completed primary artifact.""" + self.final_file_path = Path(file_path) logger.info("\nFile ready at %(file_path)s\n", {"file_path": f"'{file_path}'"}) diff --git a/manim/utils/docbuild/manim_directive.py b/manim/utils/docbuild/manim_directive.py index bf4fb554ce..ebe3cdefba 100644 --- a/manim/utils/docbuild/manim_directive.py +++ b/manim/utils/docbuild/manim_directive.py @@ -282,11 +282,10 @@ def run(self) -> list[nodes.Element]: "pixel_height": pixel_height, "pixel_width": pixel_width, "save_last_frame": save_last_frame, - "write_to_movie": not save_last_frame, "output_file": output_file, } if save_last_frame: - example_config["format"] = None + example_config["format"] = "png" if save_as_gif: example_config["format"] = "gif" diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py index f94a076184..e6fe09e1d7 100644 --- a/manim/utils/file_ops.py +++ b/manim/utils/file_ops.py @@ -9,12 +9,6 @@ "seek_full_path_from_defaults", "modify_atime", "open_file", - "is_mp4_format", - "is_gif_format", - "is_png_format", - "is_webm_format", - "is_mov_format", - "write_to_movie", "ensure_executable", ] @@ -37,98 +31,6 @@ from .. import console -def is_mp4_format() -> bool: - """ - Determines if output format is .mp4 - - Returns - ------- - class:`bool` - ``True`` if format is set as mp4 - - """ - val: bool = config["format"] == "mp4" - return val - - -def is_gif_format() -> bool: - """ - Determines if output format is .gif - - Returns - ------- - class:`bool` - ``True`` if format is set as gif - - """ - val: bool = config["format"] == "gif" - return val - - -def is_webm_format() -> bool: - """ - Determines if output format is .webm - - Returns - ------- - class:`bool` - ``True`` if format is set as webm - - """ - val: bool = config["format"] == "webm" - return val - - -def is_mov_format() -> bool: - """ - Determines if output format is .mov - - Returns - ------- - class:`bool` - ``True`` if format is set as mov - - """ - val: bool = config["format"] == "mov" - return val - - -def is_png_format() -> bool: - """ - Determines if output format is .png - - Returns - ------- - class:`bool` - ``True`` if format is set as png - - """ - val: bool = config["format"] == "png" - return val - - -def write_to_movie() -> bool: - """ - Determines from config if the output is a video format such as mp4 or gif, if the --format is set as 'png' - then it will take precedence event if the write_to_movie flag is set - - Returns - ------- - class:`bool` - ``True`` if the output should be written in a movie format - - """ - if is_png_format(): - return False - return ( - config["write_to_movie"] - or is_mp4_format() - or is_gif_format() - or is_webm_format() - or is_mov_format() - ) - - def ensure_executable(path_to_exe: Path) -> bool: if path_to_exe.parent == Path("."): executable: StrPath | None = shutil.which(path_to_exe.stem) @@ -221,14 +123,11 @@ def open_file(file_path: Path, in_browser: bool = False) -> None: def open_media_file(file_writer: SceneFileWriter) -> None: - file_paths = [] - - if config["save_last_frame"]: - file_paths.append(file_writer.image_file_path) - if write_to_movie() and not is_gif_format(): - file_paths.append(file_writer.movie_file_path) - if write_to_movie() and is_gif_format(): - file_paths.append(file_writer.gif_file_path) + final_file_path = getattr(file_writer, "final_file_path", None) + if final_file_path is None: + logger.warning("No media artifact is available to open.") + return + file_paths = [final_file_path] for file_path in file_paths: if config["show_in_file_browser"]: diff --git a/manim/utils/ipython_magic.py b/manim/utils/ipython_magic.py index 1d62bbc6f4..cb3040906b 100644 --- a/manim/utils/ipython_magic.py +++ b/manim/utils/ipython_magic.py @@ -150,11 +150,19 @@ def construct(self): if renderer is not None and renderer.window is not None: renderer.window.close() - if config["output_file"] is None: + output_file = getattr( + scene.renderer.file_writer, + "final_file_path", + None, + ) + if output_file is None: logger.info("No output file produced") return + if output_file.is_dir(): + logger.info("Image-sequence output is not displayed in notebooks") + return - local_path = Path(config["output_file"]).relative_to(Path.cwd()) + local_path = output_file.relative_to(Path.cwd()) tmpfile = ( Path(config["media_dir"]) / "jupyter" @@ -167,7 +175,7 @@ def construct(self): tmpfile.parent.mkdir(parents=True, exist_ok=True) shutil.copy(local_path, tmpfile) - file_type = mimetypes.guess_type(config["output_file"])[0] + file_type = mimetypes.guess_type(output_file)[0] assert isinstance(file_type, str) embed = config["media_embed"] if not embed: @@ -177,7 +185,7 @@ def construct(self): embed = "google.colab" in str(get_ipython()) if file_type.startswith("image"): - result = Image(filename=config["output_file"]) + result = Image(filename=output_file) else: result = Video( tmpfile, diff --git a/tests/helpers/graphical_units.py b/tests/helpers/graphical_units.py index 232a6510eb..9438f930e7 100644 --- a/tests/helpers/graphical_units.py +++ b/tests/helpers/graphical_units.py @@ -31,7 +31,7 @@ def set_test_scene(scene_object: type[Scene], module_name: str, config): set_test_scene(DotTest, "geometry") """ - config["write_to_movie"] = False + config.format = "none" config["disable_caching"] = True config["format"] = "png" config["pixel_height"] = 480 diff --git a/tests/module/test_manager.py b/tests/module/test_manager.py index df85ae6f47..259e7a631a 100644 --- a/tests/module/test_manager.py +++ b/tests/module/test_manager.py @@ -9,6 +9,7 @@ import srt from manim import Manager, Scene, tempconfig +from manim._config.output import OutputFormat from manim.animation.animation import Wait from manim.constants import RendererType from manim.scene.scene import SceneInteractRerun @@ -24,6 +25,18 @@ def test_manager_attaches_to_existing_scene(dry_run): assert scene.manager is manager +def test_manager_exposes_the_session_output_snapshot(config): + config.dry_run = False + config.format = "gif" + scene = Scene() + manager = Manager(scene) + + config.format = "none" + + assert manager.output_spec.format is OutputFormat.GIF + assert manager.output_spec is scene.renderer.file_writer.output_spec + + def test_manager_rejects_second_attachment(dry_run): scene = Scene() manager = Manager(scene) diff --git a/tests/opengl/test_config_opengl.py b/tests/opengl/test_config_opengl.py index d0ca0e5a81..b08721f64b 100644 --- a/tests/opengl/test_config_opengl.py +++ b/tests/opengl/test_config_opengl.py @@ -6,6 +6,7 @@ import numpy as np from manim import WHITE, Scene, Square, tempconfig +from manim._config.output import resolve_output_spec def test_tempconfig(config, using_opengl_renderer): @@ -112,15 +113,12 @@ def test_frame_size_if_frame_width(config, using_opengl_renderer, tmp_path): def test_temporary_dry_run(config, using_opengl_renderer): """Test that tempconfig correctly restores after setting dry_run.""" - assert config["write_to_movie"] - assert not config["save_last_frame"] + assert resolve_output_spec(config).is_video with tempconfig({"dry_run": True}): - assert not config["write_to_movie"] - assert not config["save_last_frame"] + assert not resolve_output_spec(config).enabled - assert config["write_to_movie"] - assert not config["save_last_frame"] + assert resolve_output_spec(config).is_video def test_dry_run_with_png_format(config, using_opengl_renderer, dry_run): @@ -135,7 +133,7 @@ def test_dry_run_with_png_format_skipped_animations( config, using_opengl_renderer, dry_run ): """Test that there are no exceptions when running a png without output and skipped animations""" - config.write_to_movie = False + config.format = "png" config.disable_caching = True assert config["dry_run"] is True scene = MyScene(skip_animations=True) diff --git a/tests/test_config.py b/tests/test_config.py index f80b5808ee..4183af8ff6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,9 +5,12 @@ import numpy as np import pytest +from click.testing import CliRunner from manim import RIGHT, WHITE, Scene, Square, Tex, Text, Vector, tempconfig +from manim._config.output import OutputFormat, resolve_output_spec from manim._config.utils import ManimConfig +from manim.cli.render.commands import render from manim.constants import RendererType from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject from manim.mobject.types.vectorized_mobject import VMobject @@ -57,9 +60,107 @@ def test_tempconfig_restores_renderer_class_bases(config): ("gif", ".mp4"), ], ) -def test_resolve_file_extensions(config, format, expected_file_extension): +def test_resolve_segment_extensions(config, format, expected_file_extension): config.format = format - assert config.movie_file_extension == expected_file_extension + assert resolve_output_spec(config).segment_extension == expected_file_extension + + +@pytest.mark.parametrize( + ("format", "expected_format"), + [ + ("auto", OutputFormat.MP4), + ("none", OutputFormat.NONE), + ("png", OutputFormat.PNG), + ("png-sequence", OutputFormat.PNG_SEQUENCE), + ("gif", OutputFormat.GIF), + ], +) +def test_resolve_output_spec(config, format, expected_format): + config.format = format + + assert resolve_output_spec(config).format is expected_format + + +def test_transparent_auto_output_resolves_to_mov(config): + config.format = "auto" + config.transparent = True + + assert resolve_output_spec(config).format is OutputFormat.MOV + + +def test_explicit_transparent_mp4_is_rejected(config): + config.format = "mp4" + config.transparent = True + + with pytest.raises(ValueError, match="does not support an alpha channel"): + resolve_output_spec(config) + + +def test_dry_run_resolves_no_output_without_mutating_output_request(config): + config.format = "gif" + config.dry_run = True + + assert resolve_output_spec(config).format is OutputFormat.NONE + assert config.format == "gif" + + +def test_save_last_frame_resolves_to_still_output(config): + config.format = "auto" + config.save_last_frame = True + + assert resolve_output_spec(config).format is OutputFormat.PNG + + +def test_save_last_frame_alias_works_with_tempconfig(config): + original_format = config.format + + with tempconfig({"save_last_frame": True}): + assert config.format == "png" + assert resolve_output_spec(config).is_still + + assert config.format == original_format + + +def test_sections_require_video_output(config): + config.format = "png" + config.save_sections = True + + with pytest.raises(ValueError, match="Section output requires"): + resolve_output_spec(config) + + +def test_format_is_loaded_from_config_file(tmp_path, config): + config_file = tmp_path / "output.cfg" + config_file.write_text("[CLI]\nformat = png-sequence\n") + + config.digest_file(config_file) + + assert config.format == "png-sequence" + + +def test_absent_cli_output_options_preserve_config_file_values(tmp_path): + scene_file = tmp_path / "scene.py" + scene_file.write_text("# --jupyter returns before loading this file\n") + config_file = tmp_path / "output.cfg" + config_file.write_text( + "[CLI]\n" + "format = webm\n" + "output_file = configured-name\n" + "background_opacity = 0.5\n", + ) + result = CliRunner().invoke( + render, + [str(scene_file), "--jupyter"], + standalone_mode=False, + ) + assert result.exception is None + + candidate = ManimConfig().digest_file(config_file) + candidate.digest_args(result.return_value) + + assert candidate.format == "webm" + assert candidate.output_file == "configured-name" + assert candidate.transparent is True class MyScene(Scene): @@ -96,7 +197,6 @@ def test_transparent_by_background_opacity(config, dry_run): scene.render() frame = scene.renderer.get_frame() np.testing.assert_allclose(frame[0, 0], [0, 0, 0, 127]) - assert config.movie_file_extension == ".mov" assert config.transparent is True @@ -213,20 +313,17 @@ def test_frame_size(tmp_path, config): def test_temporary_dry_run(config): """Test that tempconfig correctly restores after setting dry_run.""" - assert config["write_to_movie"] - assert not config["save_last_frame"] + assert resolve_output_spec(config).is_video with tempconfig({"dry_run": True}): - assert not config["write_to_movie"] - assert not config["save_last_frame"] + assert not resolve_output_spec(config).enabled - assert config["write_to_movie"] - assert not config["save_last_frame"] + assert resolve_output_spec(config).is_video def test_dry_run_with_png_format(config, dry_run): """Test that there are no exceptions when running a png without output""" - config.write_to_movie = False + config.format = "png" config.disable_caching = True assert config.dry_run is True scene = MyScene() @@ -235,7 +332,7 @@ def test_dry_run_with_png_format(config, dry_run): def test_dry_run_with_png_format_skipped_animations(config, dry_run): """Test that there are no exceptions when running a png without output and skipped animations""" - config.write_to_movie = False + config.format = "png" config.disable_caching = True assert config["dry_run"] is True scene = MyScene(skip_animations=True) diff --git a/tests/test_scene_rendering/conftest.py b/tests/test_scene_rendering/conftest.py index 7263a3f37c..d2b889422f 100644 --- a/tests/test_scene_rendering/conftest.py +++ b/tests/test_scene_rendering/conftest.py @@ -43,12 +43,12 @@ def infallible_scenes_path(): @pytest.fixture -def force_window_config_write_to_movie(config): +def force_window_config_movie(config): config.force_window = True - config.write_to_movie = True + config.format = "mp4" @pytest.fixture def force_window_config_pngs(config): config.force_window = True - config.format = "png" + config.format = "png-sequence" diff --git a/tests/test_scene_rendering/opengl/test_caching_related_opengl.py b/tests/test_scene_rendering/opengl/test_caching_related_opengl.py index c9c82a449b..142ece5e24 100644 --- a/tests/test_scene_rendering/opengl/test_caching_related_opengl.py +++ b/tests/test_scene_rendering/opengl/test_caching_related_opengl.py @@ -23,7 +23,7 @@ def test_wait_skip(tmp_path, manim_cfg_file, simple_scenes_path): "manim", "--renderer", "opengl", - "--write_to_movie", + "--format=mp4", "-ql", "--media_dir", str(tmp_path), @@ -50,7 +50,7 @@ def test_play_skip(tmp_path, manim_cfg_file, simple_scenes_path): "manim", "--renderer", "opengl", - "--write_to_movie", + "--format=mp4", "-ql", "--media_dir", str(tmp_path), diff --git a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py index 17f6f35e38..5cc4fafeeb 100644 --- a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py +++ b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py @@ -26,7 +26,7 @@ def test_basic_scene_with_default_values(tmp_path, manim_cfg_file, simple_scenes "manim", "--renderer", "opengl", - "--write_to_movie", + "--format=mp4", "--media_dir", str(tmp_path), str(simple_scenes_path), @@ -87,7 +87,7 @@ def test_basic_scene_l_flag(tmp_path, manim_cfg_file, simple_scenes_path): "--renderer", "opengl", "-ql", - "--write_to_movie", + "--format=mp4", "--media_dir", str(tmp_path), str(simple_scenes_path), @@ -111,7 +111,7 @@ def test_n_flag(tmp_path, simple_scenes_path): "-ql", "--renderer", "opengl", - "--write_to_movie", + "--format=mp4", "-n 3,6", "--media_dir", str(tmp_path), @@ -233,7 +233,7 @@ def test_no_default_image_output_with_non_static_scene( @pytest.mark.slow -def test_image_output_for_static_scene_with_write_to_movie( +def test_image_output_for_static_scene_with_video_format( tmp_path, manim_cfg_file, simple_scenes_path ): scene_name = "StaticScene" @@ -241,7 +241,7 @@ def test_image_output_for_static_scene_with_write_to_movie( sys.executable, "-m", "manim", - "--write_to_movie", + "--format=mp4", "--renderer", "opengl", "-ql", @@ -324,7 +324,7 @@ def test_a_flag(tmp_path, manim_cfg_file, infallible_scenes_path): "manim", "--renderer", "opengl", - "--write_to_movie", + "--format=mp4", "-ql", "--media_dir", str(tmp_path), @@ -633,7 +633,7 @@ def test_default_format_output_for_transparent_flag( "--renderer", "opengl", "-ql", - "--write_to_movie", + "--format=auto", "--media_dir", str(tmp_path), "-t", diff --git a/tests/test_scene_rendering/opengl/test_opengl_renderer.py b/tests/test_scene_rendering/opengl/test_opengl_renderer.py index f2236cb04d..0b16bb3c2c 100644 --- a/tests/test_scene_rendering/opengl/test_opengl_renderer.py +++ b/tests/test_scene_rendering/opengl/test_opengl_renderer.py @@ -11,32 +11,32 @@ from tests.test_scene_rendering.simple_scenes import * -def test_write_to_movie_disables_window( +def test_file_output_disables_window( config, using_temp_opengl_config, disabling_caching ): - """write_to_movie should disable window by default""" + """File output should disable the 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) + assert_file_exists(renderer.file_writer.final_file_path) @pytest.mark.skip(reason="Temporarily skip due to failing in Windows CI") def test_force_window_opengl_render_with_movies( config, using_temp_opengl_config, - force_window_config_write_to_movie, + force_window_config_movie, disabling_caching, ): - """force_window creates window when write_to_movie is set""" + """force_window creates a window while movie output is enabled.""" 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"]) + assert_file_exists(renderer.file_writer.final_file_path) renderer.window.close() diff --git a/tests/test_scene_rendering/opengl/test_play_logic_opengl.py b/tests/test_scene_rendering/opengl/test_play_logic_opengl.py index 64c4c39204..9a49ef9ffb 100644 --- a/tests/test_scene_rendering/opengl/test_play_logic_opengl.py +++ b/tests/test_scene_rendering/opengl/test_play_logic_opengl.py @@ -93,10 +93,13 @@ def test_t_values_with_cached_data(using_temp_opengl_config): assert scene.update_to_time.call_count == 10 -@pytest.mark.xfail(reason="Not currently handled correctly for opengl") -def test_t_values_save_last_frame(config, using_temp_opengl_config): - """Test that there is only one t value handled when only saving the last frame""" - config.save_last_frame = True +@pytest.mark.parametrize( + "still_config", + [{"save_last_frame": True}, {"format": "png"}], +) +def test_t_values_save_last_frame(config, using_temp_opengl_config, still_config): + """Still output fast-forwards each play and only evaluates its final state.""" + config.update(still_config) scene = SquareToCircle() scene.update_to_time = Mock() scene.render() diff --git a/tests/test_scene_rendering/test_cairo_renderer.py b/tests/test_scene_rendering/test_cairo_renderer.py index 5134578aa3..58af612b88 100644 --- a/tests/test_scene_rendering/test_cairo_renderer.py +++ b/tests/test_scene_rendering/test_cairo_renderer.py @@ -18,7 +18,8 @@ def test_render(using_temp_config, disabling_caching): scene.render() assert renderer.add_frame.call_count == config["frame_rate"] assert renderer.update_frame.call_count == config["frame_rate"] - assert_file_exists(config["output_file"]) + assert_file_exists(renderer.file_writer.final_file_path) + assert config.output_file == "" def test_skipping_status_with_from_to_and_up_to(using_temp_config, disabling_caching): @@ -60,7 +61,7 @@ def test_when_animation_is_cached(using_temp_config): # Check that manim correctly skipped the animation. scene.update_to_time.assert_called_once_with(1) # Check that the output video has been generated. - assert_file_exists(config["output_file"]) + assert_file_exists(scene.renderer.file_writer.final_file_path) def test_hash_logic_is_not_called_when_caching_is_disabled( @@ -71,7 +72,7 @@ def test_hash_logic_is_not_called_when_caching_is_disabled( scene = SquareToCircle() scene.render() mocked.assert_not_called() - assert_file_exists(config["output_file"]) + assert_file_exists(scene.renderer.file_writer.final_file_path) def test_hash_logic_is_called_when_caching_is_enabled(using_temp_config): diff --git a/tests/test_scene_rendering/test_cli_flags.py b/tests/test_scene_rendering/test_cli_flags.py index 282dd1b50a..f3d51b567c 100644 --- a/tests/test_scene_rendering/test_cli_flags.py +++ b/tests/test_scene_rendering/test_cli_flags.py @@ -140,14 +140,15 @@ def test_s_flag_no_animations(tmp_path, manim_cfg_file, simple_scenes_path): @pytest.mark.slow -def test_s_flag(tmp_path, manim_cfg_file, simple_scenes_path): +@pytest.mark.parametrize("still_flag", ["-s", "--format=png"]) +def test_s_flag(tmp_path, manim_cfg_file, simple_scenes_path, still_flag): scene_name = "SquareToCircle" command = [ sys.executable, "-m", "manim", "-ql", - "-s", + still_flag, "--media_dir", str(tmp_path), str(simple_scenes_path), diff --git a/tests/test_scene_rendering/test_file_writer.py b/tests/test_scene_rendering/test_file_writer.py index 17404453f2..e8cd2ce3d0 100644 --- a/tests/test_scene_rendering/test_file_writer.py +++ b/tests/test_scene_rendering/test_file_writer.py @@ -198,7 +198,7 @@ def test_clean_cache_ignores_hidden_files(config, tmp_path): # macOS leaves resource forks (._*.mp4) and .DS_Store files in the # partial movie directory; they must not be counted against # max_files_cached nor be deleted, see issue #3234. - with tempconfig({"media_dir": tmp_path, "write_to_movie": True}): + with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("CacheCleaningScene") cache_dir = writer.partial_movie_directory @@ -223,7 +223,7 @@ def test_clean_cache_ignores_hidden_files(config, tmp_path): def test_flush_cache_directory_ignores_hidden_files(config, tmp_path): - with tempconfig({"media_dir": tmp_path, "write_to_movie": True}): + with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("CacheFlushingScene") cache_dir = writer.partial_movie_directory @@ -244,7 +244,7 @@ def test_clean_cache_tolerates_vanishing_files(config, tmp_path, monkeypatch): # A file can disappear between listing the directory and unlinking it # (e.g. Finder removing a transient resource fork); clean_cache must # not raise FileNotFoundError in that case. - with tempconfig({"media_dir": tmp_path, "write_to_movie": True}): + with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("VanishingFileScene") cache_dir = writer.partial_movie_directory @@ -262,7 +262,7 @@ def test_clean_cache_tolerates_vanishing_files(config, tmp_path, monkeypatch): def test_clean_cache_does_not_evict_for_vanished_file(config, tmp_path, monkeypatch): - with tempconfig({"media_dir": tmp_path, "write_to_movie": True}): + with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("VanishedFileEvictionScene") cache_dir = writer.partial_movie_directory diff --git a/tests/test_scene_rendering/test_parallel_encoding.py b/tests/test_scene_rendering/test_parallel_encoding.py index 474a6fb89f..a9cedebf47 100644 --- a/tests/test_scene_rendering/test_parallel_encoding.py +++ b/tests/test_scene_rendering/test_parallel_encoding.py @@ -481,7 +481,7 @@ def test_is_already_cached_joins_same_path_inflight_job(config, tmp_path): hash_invocation = "same_path_hash" path = ( writer.partial_movie_directory - / f"{hash_invocation}{config['movie_file_extension']}" + / f"{hash_invocation}{writer.output_spec.segment_extension}" ) job = Mock(path=path) writer._inflight_encode_jobs.append(job) @@ -504,7 +504,7 @@ def test_same_path_join_failure_drains_unrelated_jobs(config, tmp_path): hash_invocation = "failing_same_path_hash" path = ( writer.partial_movie_directory - / f"{hash_invocation}{config['movie_file_extension']}" + / f"{hash_invocation}{writer.output_spec.segment_extension}" ) unrelated_job = Mock(path=tmp_path / "unrelated.mp4") same_path_job = Mock(path=path) @@ -1021,7 +1021,7 @@ def test_is_already_cached_false_after_joining_failed_path(config, tmp_path): hash_invocation = "missing_partial_hash" path = ( writer.partial_movie_directory - / f"{hash_invocation}{config['movie_file_extension']}" + / f"{hash_invocation}{writer.output_spec.segment_extension}" ) job = Mock(path=path) writer._inflight_encode_jobs.append(job) @@ -1041,7 +1041,7 @@ def test_is_already_cached_true_when_partial_exists(config, tmp_path): hash_invocation = "present_partial_hash" path = ( writer.partial_movie_directory - / f"{hash_invocation}{config['movie_file_extension']}" + / f"{hash_invocation}{writer.output_spec.segment_extension}" ) path.write_bytes(b"cached partial") @@ -1076,8 +1076,8 @@ def test_open_partial_movie_stream_without_path_raises(config, tmp_path): def test_write_frame_without_open_stream_drops_frame(config, tmp_path): """Interactive OpenGL emits frames with no open stream; they are dropped. - ``write_to_movie()`` is true under the default test config, so the call - reaches the drop branch in ``write_frame``. + Video output is enabled under the default test config, so the call reaches + the drop branch in ``write_frame``. """ from manim.scene.scene_file_writer import SceneFileWriter diff --git a/tests/test_scene_rendering/test_play_logic.py b/tests/test_scene_rendering/test_play_logic.py index 4aaae3c8ad..cb5fdd1b46 100644 --- a/tests/test_scene_rendering/test_play_logic.py +++ b/tests/test_scene_rendering/test_play_logic.py @@ -112,15 +112,32 @@ def test_t_values_with_cached_data(using_temp_config): assert scene.update_to_time.call_count == 10 -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 +@pytest.mark.parametrize( + "still_config", + [{"save_last_frame": True}, {"format": "png"}], +) +def test_t_values_save_last_frame(config, using_temp_config, still_config): + """Still output fast-forwards each play and only evaluates its final state.""" + config.update(still_config) scene = SquareToCircle() scene.update_to_time = Mock() scene.render() scene.update_to_time.assert_called_once_with(1) +def test_png_sequence_evaluates_every_frame(config, using_temp_config): + config.format = "png-sequence" + scene = SquareToCircle() + scene.update_to_time = Mock() + + scene.render() + + assert scene.update_to_time.call_count == config.frame_rate + assert scene.renderer.file_writer.frame_count == config.frame_rate + assert scene.renderer.file_writer.final_file_path.is_dir() + assert (scene.renderer.file_writer.final_file_path / "0000.png").is_file() + + def test_animate_with_changed_custom_attribute(using_temp_config): """Test that animating the change of a custom attribute using the animate syntax works correctly. From f29ab5b8b759993972e74564c0885b425edb35ef Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 11:32:43 +0200 Subject: [PATCH 02/14] Document canonical output formats --- docs/source/faq/general.md | 5 +++-- docs/source/guides/configuration.rst | 22 ++++++++++++--------- docs/source/tutorials/output_and_config.rst | 5 ++++- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/source/faq/general.md b/docs/source/faq/general.md index 8ac092f5f1..f8286396fc 100644 --- a/docs/source/faq/general.md +++ b/docs/source/faq/general.md @@ -76,8 +76,9 @@ ones that are documented for the base classes {class}`.VMobject` and Yes: simply pass the CLI flag `-t` (or its long form `--transparent`). Note that the default video file format does not support transparency, which is why Manim will output a `.mov` instead of a `.mp4` when -rendering with a transparent background. Other movie file formats -that support transparency can be obtained by passing +rendering with a transparent background. Explicitly requesting +`--format=mp4` together with transparency is rejected. Other movie file +formats that support transparency can be obtained by passing `--format=webm` or `--format=gif`. --- diff --git a/docs/source/guides/configuration.rst b/docs/source/guides/configuration.rst index 3ea27b89db..d1e00cb33d 100644 --- a/docs/source/guides/configuration.rst +++ b/docs/source/guides/configuration.rst @@ -53,6 +53,10 @@ instead of the whole video, you can execute manim -sqh SceneName +The equivalent ``--format=png`` spelling uses the same fast final-state-only +evaluation. Use ``--format=png-sequence`` when every rendered frame should be +written as a numbered PNG instead. + The following example specifies the output file name (with the :code:`-o` flag), renders only the first ten animations (:code:`-n` flag) with a white background (:code:`-c` flag), and saves the animation as a ``.gif`` instead of as a @@ -161,7 +165,7 @@ and serve the same purpose. Take, for example, the following config file. [CLI] # my config file output_file = myscene - save_as_gif = True + format = gif background_color = WHITE Config files are parsed with the standard python library ``configparser``. In @@ -171,7 +175,7 @@ Now, executing the following command .. code-block:: bash - manim -o myscene -i -c WHITE SceneName + manim -o myscene --format=gif -c WHITE SceneName is equivalent to executing the following command, provided that ``manim.cfg`` is in the same directory as , @@ -253,7 +257,7 @@ For example, take the following user-wide config file # user-wide [CLI] output_file = myscene - save_as_gif = True + format = gif background_color = WHITE and the following folder-wide file @@ -262,7 +266,7 @@ and the following folder-wide file # folder-wide [CLI] - save_as_gif = False + format = auto Then, executing :code:`manim SceneName` will be equivalent to not using any config files and executing @@ -355,14 +359,14 @@ A list of all config options 'frame_size', 'frame_width', 'frame_x_radius', 'frame_y_radius', 'from_animation_number', `fullscreen`, 'images_dir', 'input_file', 'left_side', 'log_dir', 'log_to_file', 'max_files_cached', 'max_inflight_encoders', - 'media_dir', 'media_width', 'movie_file_extension', 'notify_outdated_version', - 'output_file', 'partial_movie_dir', + 'media_dir', 'media_width', 'notify_outdated_version', 'output_file', + 'partial_movie_dir', 'pixel_height', 'pixel_width', 'plugins', 'preview', - 'progress_bar', 'quality', 'right_side', 'save_as_gif', 'save_last_frame', - 'save_pngs', 'scene_names', 'show_in_file_browser', 'sound', 'tex_dir', + 'progress_bar', 'quality', 'right_side', 'save_last_frame', 'scene_names', + 'show_in_file_browser', 'sound', 'tex_dir', 'tex_template', 'tex_template_file', 'text_dir', 'top', 'transparent', 'upto_animation_number', 'use_opengl_renderer', 'verbosity', 'video_dir', - 'window_position', 'window_monitor', 'window_size', 'write_all', 'write_to_movie', + 'window_position', 'window_monitor', 'window_size', 'write_all', 'enable_wireframe', 'force_window'] diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index af7961d873..3c041b76f8 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -132,7 +132,10 @@ The corresponding folder structure looks like this: └─Tex Saving the last frame with ``-s`` can be combined with the flags for different -resolutions, e.g. ``-s -ql``, ``-s -qh`` +resolutions, e.g. ``-s -ql``, ``-s -qh``. The equivalent +``--format=png`` spelling also selects this fast final-state-only mode. To write +every rendered frame as a numbered PNG instead, use +``--format=png-sequence``. From 7b04d7dbf88be83fd88c69597c7729e070569dfa Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 11:44:07 +0200 Subject: [PATCH 03/14] Separate live and post-render preview --- manim/_config/default.cfg | 10 +-- manim/_config/output.py | 7 +- manim/_config/render_session.py | 86 +++++++++++++++++++ manim/_config/utils.py | 37 +++----- manim/cli/render/commands.py | 5 -- manim/cli/render/ease_of_access_options.py | 13 ++- manim/cli/render/global_options.py | 6 -- manim/manager.py | 31 +++++-- manim/renderer/cairo_renderer.py | 10 +++ manim/renderer/opengl_renderer.py | 33 ++++--- manim/renderer/protocol.py | 16 ++++ manim/scene/scene.py | 6 +- manim/scene/scene_file_writer.py | 3 +- manim/utils/file_ops.py | 11 ++- tests/module/test_manager.py | 10 +++ tests/test_config.py | 77 +++++++++++++++++ tests/test_scene_rendering/conftest.py | 8 +- .../opengl/test_opengl_renderer.py | 33 +++---- 18 files changed, 307 insertions(+), 95 deletions(-) create mode 100644 manim/_config/render_session.py create mode 100644 manim/renderer/protocol.py diff --git a/manim/_config/default.cfg b/manim/_config/default.cfg index 1c8ee7219c..6c6cd9f402 100644 --- a/manim/_config/default.cfg +++ b/manim/_config/default.cfg @@ -24,10 +24,13 @@ zero_pad = 4 # --save_sections save_sections = False -# -p, --preview +# -p, --preview: open the completed artifact after rendering preview = False -# -f, --show_in_file_browser +# -l, --live-preview: display frames while rendering when supported +live_preview = False + +# --show_in_file_browser show_in_file_browser = False # -v, --verbosity @@ -101,9 +104,6 @@ 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/output.py b/manim/_config/output.py index a8dbf858c7..60948eed31 100644 --- a/manim/_config/output.py +++ b/manim/_config/output.py @@ -114,6 +114,8 @@ class _OutputConfigSource(Protocol): save_last_frame: bool save_sections: bool transparent: bool + live_preview: bool + enable_gui: bool dry_run: bool @@ -130,7 +132,10 @@ def resolve_output_spec(config: _OutputConfigSource) -> OutputSpec: if config.save_last_frame: requested = OutputFormat.PNG elif requested is OutputFormat.AUTO: - requested = OutputFormat.MOV if config.transparent else OutputFormat.MP4 + if config.live_preview or config.enable_gui: + requested = OutputFormat.NONE + else: + requested = OutputFormat.MOV if config.transparent else OutputFormat.MP4 return OutputSpec( format=requested, diff --git a/manim/_config/render_session.py b/manim/_config/render_session.py new file mode 100644 index 0000000000..7f0190563f --- /dev/null +++ b/manim/_config/render_session.py @@ -0,0 +1,86 @@ +"""Resolved render-session configuration.""" + +from __future__ import annotations + +__all__ = [ + "PresentationSpec", + "RenderSessionSpec", + "resolve_render_session", +] + +from dataclasses import dataclass +from typing import Protocol + +from manim.renderer.protocol import RendererCapabilities + +from .output import OutputFormat, OutputSpec, resolve_output_spec + + +@dataclass(frozen=True, slots=True) +class PresentationSpec: + """Immutable presentation requests for one render session.""" + + open_after_render: bool + live_preview: bool + show_in_file_browser: bool + + +@dataclass(frozen=True, slots=True) +class RenderSessionSpec: + """Validated output and presentation intent for one render session.""" + + output: OutputSpec + presentation: PresentationSpec + + +class _SessionConfigSource(Protocol): + format: str | OutputFormat | None + save_last_frame: bool + save_sections: bool + transparent: bool + preview: bool + live_preview: bool + show_in_file_browser: bool + enable_gui: bool + dry_run: bool + + +def resolve_render_session( + config: _SessionConfigSource, + capabilities: RendererCapabilities, + *, + renderer_name: str, +) -> RenderSessionSpec: + """Resolve and validate one renderer-independent session request.""" + output = resolve_output_spec(config) + live_preview = config.live_preview or config.enable_gui + presentation = PresentationSpec( + open_after_render=config.preview, + live_preview=live_preview, + show_in_file_browser=config.show_in_file_browser, + ) + + if live_preview and not capabilities.live_preview: + raise ValueError( + f"{renderer_name} does not support live preview. " + "Select a renderer with live-preview support or remove --live-preview.", + ) + if live_preview and config.dry_run: + raise ValueError("--live-preview cannot be combined with --dry_run.") + if live_preview and output.is_still: + raise ValueError( + "Live preview cannot be combined with final-state-only PNG output.", + ) + if live_preview and output.enabled and not capabilities.live_preview_with_output: + raise ValueError( + f"{renderer_name} cannot produce media output while live preview is active.", + ) + if presentation.open_after_render and not output.enabled: + raise ValueError( + "--preview requires a media artifact. Choose a concrete --format when " + "using --live-preview.", + ) + if presentation.show_in_file_browser and not output.enabled: + raise ValueError("--show_in_file_browser requires a media artifact.") + + return RenderSessionSpec(output=output, presentation=presentation) diff --git a/manim/_config/utils.py b/manim/_config/utils.py index 26f785cc78..4eb89964bd 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -294,6 +294,7 @@ class MyScene(Scene): ... "pixel_width", "plugins", "preview", + "live_preview", "progress_bar", "quality", "save_sections", @@ -319,7 +320,6 @@ class MyScene(Scene): ... "window_monitor", "write_all", "zero_pad", - "force_window", "no_latex_cleanup", "preview_command", } @@ -584,6 +584,7 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: "write_all", "save_sections", "preview", + "live_preview", "show_in_file_browser", "log_to_file", "disable_caching", @@ -595,7 +596,6 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: "use_projection_fill_shaders", "use_projection_stroke_shaders", "enable_wireframe", - "force_window", "no_latex_cleanup", "dry_run", ]: @@ -754,6 +754,7 @@ def digest_args(self, args: argparse.Namespace) -> Self: for key in [ "notify_outdated_version", "preview", + "live_preview", "show_in_file_browser", "save_last_frame", "save_sections", @@ -773,7 +774,6 @@ def digest_args(self, args: argparse.Namespace) -> Self: "use_projection_stroke_shaders", "zero_pad", "enable_wireframe", - "force_window", "dry_run", "no_latex_cleanup", "preview_command", @@ -845,15 +845,6 @@ def digest_args(self, args: argparse.Namespace) -> Self: if args.tex_template: self.tex_template = TexTemplate.from_file(args.tex_template) - # Preserve OpenGL's existing opt-in file-output behavior until live - # preview and post-render preview become separate session requests. - if ( - self.renderer == RendererType.OPENGL - and args.format is None - and OutputFormat.parse(self.format) is OutputFormat.AUTO - ): - self.format = OutputFormat.NONE - # Handle --gui_location flag. if args.gui_location is not None: self.gui_location = args.gui_location @@ -904,13 +895,22 @@ def digest_file(self, filename: StrPath) -> Self: @property def preview(self) -> bool: - """Whether to play the rendered movie (-p).""" - return self._d["preview"] or self._d["enable_gui"] + """Whether to open the completed artifact after rendering (-p).""" + return self._d["preview"] @preview.setter def preview(self, value: bool) -> None: self._set_boolean("preview", value) + @property + def live_preview(self) -> bool: + """Whether to display frames in a renderer-provided live preview (-l).""" + return self._d["live_preview"] + + @live_preview.setter + def live_preview(self, value: bool) -> None: + self._set_boolean("live_preview", value) + @property def show_in_file_browser(self) -> bool: """Whether to show the output file in the file browser (-f).""" @@ -988,15 +988,6 @@ def enable_wireframe(self) -> bool: def enable_wireframe(self, value: bool) -> None: self._set_boolean("enable_wireframe", value) - @property - def force_window(self) -> bool: - """Whether to force window when using the opengl renderer.""" - return self._d["force_window"] - - @force_window.setter - def force_window(self, value: bool) -> None: - self._set_boolean("force_window", value) - @property def no_latex_cleanup(self) -> bool: """Prevents deletion of .aux, .dvi, and .log files produced by Tex and MathTex.""" diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index 5a11929bc5..a4768b2224 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -76,11 +76,6 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: SCENES is an optional list of scenes in the file. """ - if kwargs["show_in_file_browser"]: - logger.warning( - "The short form of show_in_file_browser is deprecated and will be moved to support --format.", - ) - click_args = ClickArgs(kwargs) if kwargs["jupyter"]: return click_args diff --git a/manim/cli/render/ease_of_access_options.py b/manim/cli/render/ease_of_access_options.py index ac5e78f6a7..4a5b4ab51f 100644 --- a/manim/cli/render/ease_of_access_options.py +++ b/manim/cli/render/ease_of_access_options.py @@ -20,13 +20,18 @@ "-p", "--preview", is_flag=True, - help="Preview the Scene's animation. OpenGL does a live preview in a " - "popup window. Cairo opens the rendered video file in the system " - "default media player.", + help="Open the completed media artifact after rendering.", + default=None, + ), + option( + "-l", + "--live-preview", + is_flag=True, + help="Display frames in a renderer-provided live preview. With " + "--format=auto, no media file is written.", default=None, ), option( - "-f", "--show_in_file_browser", is_flag=True, help="Show the output file in the file browser.", diff --git a/manim/cli/render/global_options.py b/manim/cli/render/global_options.py index fc299d204a..a2ee106236 100644 --- a/manim/cli/render/global_options.py +++ b/manim/cli/render/global_options.py @@ -121,12 +121,6 @@ def validate_gui_location( 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=None, - ), option( "--dry_run", is_flag=True, diff --git a/manim/manager.py b/manim/manager.py index b8b4b7e2d4..215a1a21a0 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -7,7 +7,8 @@ import srt -from . import config, logger +from . import logger +from ._config.render_session import PresentationSpec, RenderSessionSpec from .scene.section import DefaultSectionType from .utils.exceptions import EndSceneEarlyException, RerunSceneException from .utils.file_ops import open_media_file @@ -91,6 +92,17 @@ def output_spec(self) -> OutputSpec: """Return the immutable output intent captured for this session.""" return self.file_writer.output_spec + @property + def session_spec(self) -> RenderSessionSpec: + """Return the immutable output and presentation intent for this session.""" + session_spec = getattr(self.renderer, "session_spec", None) + if isinstance(session_spec, RenderSessionSpec): + return session_spec + return RenderSessionSpec( + output=self.output_spec, + presentation=PresentationSpec(False, False, False), + ) + @property def time(self) -> float: """Return the current renderer time.""" @@ -139,6 +151,11 @@ def render(self, preview: bool = False) -> bool: ``False``. This matches the return value of :meth:`~manim.scene.scene.Scene.render`. """ + presentation = self.session_spec.presentation + open_after_render = preview or presentation.open_after_render + if open_after_render and not self.output_spec.enabled: + raise ValueError("Previewing after render requires a media artifact.") + self.setup() try: self.construct() @@ -164,12 +181,12 @@ def render(self, preview: bool = False) -> bool: self.tear_down() self.post_construct() - # If preview open up the render after rendering. - if preview: - config["preview"] = True - - if config["preview"] or config["show_in_file_browser"]: - open_media_file(self.file_writer) + if open_after_render or presentation.show_in_file_browser: + open_media_file( + self.file_writer, + preview=open_after_render, + show_in_file_browser=presentation.show_in_file_browser, + ) return False diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index 271d196b0c..39f98a4599 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -6,11 +6,13 @@ from manim.utils.hashing import get_hash_from_play_call from .. import config, logger +from .._config.render_session import resolve_render_session from ..camera.camera import Camera from ..mobject.mobject import Mobject, _AnimationBuilder from ..scene.scene_file_writer import SceneFileWriter from ..utils.exceptions import EndSceneEarlyException from ..utils.iterables import list_update +from .protocol import RendererCapabilities if TYPE_CHECKING: from manim.animation.animation import Animation @@ -33,6 +35,8 @@ class CairoRenderer: Time elapsed since initialisation of scene. """ + capabilities = RendererCapabilities() + def __init__( self, file_writer_class: type[SceneFileWriter] = SceneFileWriter, @@ -54,9 +58,15 @@ def __init__( self.static_image: PixelArray | None = None def init_scene(self, scene: Scene) -> None: + self.session_spec = resolve_render_session( + config, + self.capabilities, + renderer_name=type(self).__name__, + ) self.file_writer: Any = self._file_writer_class( self, scene.__class__.__name__, + output_spec=self.session_spec.output, ) def play( diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index e85e04af49..054dfec389 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -13,7 +13,8 @@ from PIL import Image from typing_extensions import override -from manim import config, logger +from manim import config +from manim._config.render_session import resolve_render_session from manim.mobject.opengl.opengl_mobject import ( OpenGLMobject, OpenGLPoint, @@ -36,6 +37,7 @@ rotation_matrix_transpose, rotation_matrix_transpose_from_quaternion, ) +from .protocol import RendererCapabilities from .shader import Mesh, Shader from .vectorized_mobject_rendering import ( render_opengl_vectorized_mobject_fill, @@ -484,6 +486,12 @@ class OpenGLRenderer: The window used for previewing, if any. """ + capabilities = RendererCapabilities( + live_preview=True, + live_preview_with_output=True, + interactive_embed=True, + ) + def __init__( self, file_writer_class: type[SceneFileWriter] = SceneFileWriter, @@ -533,9 +541,15 @@ def init_scene(self, scene: Scene) -> None: The scene to be rendered """ self.partial_movie_files: list[str | None] = [] + self.session_spec = resolve_render_session( + config, + self.capabilities, + renderer_name=type(self).__name__, + ) self.file_writer: SceneFileWriter = self._file_writer_class( self, scene.__class__.__name__, + output_spec=self.session_spec.output, ) self.scene = scene @@ -571,23 +585,8 @@ def should_create_window(self) -> bool: Determine whether a window should be created for rendering based on the current configuration. - Notes - ----- - A windows is always created if the 'force_window' configuration is enabled. """ - 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 - output = self.file_writer.output_spec - return ( - config["preview"] - and not output.is_still - and not output.enabled - and not config["dry_run"] - ) + return self.session_spec.presentation.live_preview def get_pixel_shape(self) -> tuple[int, int] | None: """ diff --git a/manim/renderer/protocol.py b/manim/renderer/protocol.py new file mode 100644 index 0000000000..b1916dc288 --- /dev/null +++ b/manim/renderer/protocol.py @@ -0,0 +1,16 @@ +"""Shared renderer feature declarations.""" + +from __future__ import annotations + +__all__ = ["RendererCapabilities"] + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class RendererCapabilities: + """Optional session features implemented by a renderer.""" + + live_preview: bool = False + live_preview_with_output: bool = False + interactive_embed: bool = False diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 6ebc2725c0..e46a303b70 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -1362,8 +1362,6 @@ def play_internal(self, skip_rendering: bool = False) -> None: def check_interactive_embed_is_valid(self) -> bool: assert isinstance(self.renderer, OpenGLRenderer) - if config["force_window"]: - return True if self.skip_animation_preview: logger.warning( "Disabling interactive embed as 'skip_animation_preview' is enabled", @@ -1547,8 +1545,8 @@ def interact(self, shell: Any, keyboard_thread: threading.Thread) -> None: def embed(self) -> None: assert isinstance(self.renderer, OpenGLRenderer) - if not config["preview"]: - logger.warning("Called embed() while no preview window is available.") + if not self.renderer.session_spec.presentation.live_preview: + logger.warning("Called embed() while no live preview window is available.") return if self.renderer.file_writer.output_spec.enabled: logger.warning("embed() is skipped while writing to a file.") diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index f844615838..41c5d557df 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -225,10 +225,11 @@ def __init__( self, renderer: CairoRenderer | OpenGLRenderer, scene_name: str, + output_spec: OutputSpec | None = None, **kwargs: Any, ) -> None: self.renderer = renderer - self.output_spec: OutputSpec = resolve_output_spec(config) + self.output_spec = output_spec or resolve_output_spec(config) self._inflight_encode_jobs: list[_PartialMovieEncodeJob] = [] self._inflight_by_path: dict[str, _PartialMovieEncodeJob] = {} self._current_encode_job: _PartialMovieEncodeJob | None = None diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py index e6fe09e1d7..7bf291cf0e 100644 --- a/manim/utils/file_ops.py +++ b/manim/utils/file_ops.py @@ -122,7 +122,12 @@ def open_file(file_path: Path, in_browser: bool = False) -> None: sp.run(commands) -def open_media_file(file_writer: SceneFileWriter) -> None: +def open_media_file( + file_writer: SceneFileWriter, + *, + preview: bool, + show_in_file_browser: bool, +) -> None: final_file_path = getattr(file_writer, "final_file_path", None) if final_file_path is None: logger.warning("No media artifact is available to open.") @@ -130,9 +135,9 @@ def open_media_file(file_writer: SceneFileWriter) -> None: file_paths = [final_file_path] for file_path in file_paths: - if config["show_in_file_browser"]: + if show_in_file_browser: open_file(file_path, True) - if config["preview"]: + if preview: open_file(file_path, False) logger.info(f"Previewed File at: '{file_path}'") diff --git a/tests/module/test_manager.py b/tests/module/test_manager.py index 259e7a631a..ad1ccb7257 100644 --- a/tests/module/test_manager.py +++ b/tests/module/test_manager.py @@ -28,13 +28,23 @@ def test_manager_attaches_to_existing_scene(dry_run): def test_manager_exposes_the_session_output_snapshot(config): config.dry_run = False config.format = "gif" + config.preview = True scene = Scene() manager = Manager(scene) config.format = "none" + config.preview = False assert manager.output_spec.format is OutputFormat.GIF assert manager.output_spec is scene.renderer.file_writer.output_spec + assert manager.session_spec.presentation.open_after_render is True + + +def test_post_render_preview_requires_an_artifact(dry_run): + scene = Scene() + + with pytest.raises(ValueError, match="requires a media artifact"): + Manager(scene).render(preview=True) def test_manager_rejects_second_attachment(dry_run): diff --git a/tests/test_config.py b/tests/test_config.py index 4183af8ff6..63b9d97301 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,11 +9,13 @@ from manim import RIGHT, WHITE, Scene, Square, Tex, Text, Vector, tempconfig from manim._config.output import OutputFormat, resolve_output_spec +from manim._config.render_session import resolve_render_session from manim._config.utils import ManimConfig from manim.cli.render.commands import render from manim.constants import RendererType from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject from manim.mobject.types.vectorized_mobject import VMobject +from manim.renderer.protocol import RendererCapabilities from tests.assert_utils import assert_dir_exists, assert_dir_filled, assert_file_exists @@ -88,6 +90,48 @@ def test_transparent_auto_output_resolves_to_mov(config): assert resolve_output_spec(config).format is OutputFormat.MOV +def test_live_preview_auto_output_resolves_to_none(config): + config.format = "auto" + config.live_preview = True + + assert resolve_output_spec(config).format is OutputFormat.NONE + + +def test_live_preview_requires_renderer_capability(config): + config.live_preview = True + + with pytest.raises(ValueError, match="does not support live preview"): + resolve_render_session( + config, + RendererCapabilities(), + renderer_name="TestRenderer", + ) + + +def test_preview_requires_output(config): + config.format = "none" + config.preview = True + + with pytest.raises(ValueError, match="requires a media artifact"): + resolve_render_session( + config, + RendererCapabilities(), + renderer_name="TestRenderer", + ) + + +def test_live_preview_with_output_requires_renderer_capability(config): + config.format = "mp4" + config.live_preview = True + + with pytest.raises(ValueError, match="cannot produce media output"): + resolve_render_session( + config, + RendererCapabilities(live_preview=True), + renderer_name="TestRenderer", + ) + + def test_explicit_transparent_mp4_is_rejected(config): config.format = "mp4" config.transparent = True @@ -138,6 +182,39 @@ def test_format_is_loaded_from_config_file(tmp_path, config): assert config.format == "png-sequence" +def test_cli_distinguishes_preview_from_live_preview(tmp_path): + scene_file = tmp_path / "scene.py" + scene_file.write_text("# --jupyter returns before loading this file\n") + + result = CliRunner().invoke( + render, + [str(scene_file), "--jupyter", "--preview", "--live-preview"], + standalone_mode=False, + ) + + assert result.exception is None + assert result.return_value.preview is True + assert result.return_value.live_preview is True + + +def test_opengl_cli_no_longer_disables_automatic_output(tmp_path, config): + scene_file = tmp_path / "scene.py" + scene_file.write_text("# --jupyter returns before loading this file\n") + result = CliRunner().invoke( + render, + [str(scene_file), "--jupyter", "--renderer=opengl"], + standalone_mode=False, + ) + assert result.exception is None + + config.format = "auto" + config.digest_args(result.return_value) + + assert config.renderer is RendererType.OPENGL + assert config.format == "auto" + assert resolve_output_spec(config).format is OutputFormat.MP4 + + def test_absent_cli_output_options_preserve_config_file_values(tmp_path): scene_file = tmp_path / "scene.py" scene_file.write_text("# --jupyter returns before loading this file\n") diff --git a/tests/test_scene_rendering/conftest.py b/tests/test_scene_rendering/conftest.py index d2b889422f..848e959caf 100644 --- a/tests/test_scene_rendering/conftest.py +++ b/tests/test_scene_rendering/conftest.py @@ -43,12 +43,12 @@ def infallible_scenes_path(): @pytest.fixture -def force_window_config_movie(config): - config.force_window = True +def live_preview_config_movie(config): + config.live_preview = True config.format = "mp4" @pytest.fixture -def force_window_config_pngs(config): - config.force_window = True +def live_preview_config_pngs(config): + config.live_preview = True config.format = "png-sequence" diff --git a/tests/test_scene_rendering/opengl/test_opengl_renderer.py b/tests/test_scene_rendering/opengl/test_opengl_renderer.py index 0b16bb3c2c..b0b9cded66 100644 --- a/tests/test_scene_rendering/opengl/test_opengl_renderer.py +++ b/tests/test_scene_rendering/opengl/test_opengl_renderer.py @@ -24,13 +24,13 @@ def test_file_output_disables_window( @pytest.mark.skip(reason="Temporarily skip due to failing in Windows CI") -def test_force_window_opengl_render_with_movies( +def test_live_preview_opengl_render_with_movies( config, using_temp_opengl_config, - force_window_config_movie, + live_preview_config_movie, disabling_caching, ): - """force_window creates a window while movie output is enabled.""" + """Live preview can be displayed while movie output is enabled.""" scene = SquareToCircle() renderer = scene.renderer renderer.update_frame = Mock(wraps=renderer.update_frame) @@ -43,12 +43,12 @@ def test_force_window_opengl_render_with_movies( @pytest.mark.skipif( platform.processor() == "aarch64", reason="Fails on Linux-ARM runners" ) -def test_force_window_opengl_render_with_format( +def test_live_preview_opengl_render_with_image_sequence( using_temp_opengl_config, - force_window_config_pngs, + live_preview_config_pngs, disabling_caching, ): - """force_window creates window when format is set""" + """Live preview can be displayed while an image sequence is written.""" scene = SquareToCircle() renderer = scene.renderer renderer.update_frame = Mock(wraps=renderer.update_frame) @@ -57,13 +57,13 @@ def test_force_window_opengl_render_with_format( renderer.window.close() -def test_get_frame_with_preview_disabled(config, using_opengl_renderer): - """Get frame is able to fetch frame with the correct dimensions when preview is disabled""" - config.preview = False +def test_get_frame_with_live_preview_disabled(config, using_opengl_renderer): + """Get frame has the correct dimensions without a live preview.""" + config.live_preview = False scene = SquareToCircle() assert isinstance(scene.renderer, OpenGLRenderer) - assert not config.preview + assert not config.live_preview renderer = scene.renderer renderer.update_frame(scene) @@ -75,25 +75,28 @@ def test_get_frame_with_preview_disabled(config, using_opengl_renderer): @pytest.mark.slow -def test_get_frame_with_preview_enabled(config, using_opengl_renderer): - """Get frame is able to fetch frame with the correct dimensions when preview is enabled""" - config.preview = True +def test_get_frame_with_live_preview_enabled(config, using_opengl_renderer): + """Get frame has the correct dimensions with a live preview.""" + config.live_preview = True scene = SquareToCircle() assert isinstance(scene.renderer, OpenGLRenderer) - assert config.preview is True + assert config.live_preview is True renderer = scene.renderer + assert renderer.window is not None + assert not renderer.file_writer.output_spec.enabled renderer.update_frame(scene) frame = renderer.get_frame() # height and width are flipped assert renderer.get_pixel_shape()[0] == frame.shape[1] assert renderer.get_pixel_shape()[1] == frame.shape[0] + renderer.window.close() def test_pixel_coords_to_space_coords(config, using_opengl_renderer): - config.preview = True + config.live_preview = False scene = SquareToCircle() assert isinstance(scene.renderer, OpenGLRenderer) From 421c05c7a9836e9946cda991f645a85eff65789f Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 11:44:12 +0200 Subject: [PATCH 04/14] Document renderer-independent preview modes --- docs/source/guides/configuration.rst | 21 +++++++++++++++------ docs/source/installation/docker.rst | 6 +++--- docs/source/tutorials/output_and_config.rst | 11 ++++++++--- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/source/guides/configuration.rst b/docs/source/guides/configuration.rst index d1e00cb33d..cdf6f603c9 100644 --- a/docs/source/guides/configuration.rst +++ b/docs/source/guides/configuration.rst @@ -37,11 +37,20 @@ An example of using the above form is: This asks Manim to search for a Scene class called :code:`SceneOne` inside the file ``file.py`` and render it with medium quality (specified by the ``-qm`` flag). -Another frequently used flag is ``-p`` ("preview"), which makes manim -open the rendered video after it's done rendering. +Another frequently used flag is ``-p`` ("preview"), which makes Manim +open the rendered artifact after rendering. This behavior is the same for all +renderers. -.. note:: The ``-p`` flag does not change any properties of the global - ``config`` dict. The ``-p`` flag is only a command-line convenience. +Renderers may also support a live preview while the scene is evaluated. The +OpenGL renderer provides this with ``-l`` (or ``--live-preview``): + +.. code-block:: bash + + manim --renderer=opengl -l SceneName + +With ``--format=auto``, live preview does not write a media file, keeping the +interactive workflow fast. Pass a concrete format, such as ``--format=mp4``, +to record the scene while displaying the live preview. Advanced examples ================= @@ -361,13 +370,13 @@ A list of all config options 'log_dir', 'log_to_file', 'max_files_cached', 'max_inflight_encoders', 'media_dir', 'media_width', 'notify_outdated_version', 'output_file', 'partial_movie_dir', - 'pixel_height', 'pixel_width', 'plugins', 'preview', + 'pixel_height', 'pixel_width', 'plugins', 'preview', 'live_preview', 'progress_bar', 'quality', 'right_side', 'save_last_frame', 'scene_names', 'show_in_file_browser', 'sound', 'tex_dir', 'tex_template', 'tex_template_file', 'text_dir', 'top', 'transparent', 'upto_animation_number', 'use_opengl_renderer', 'verbosity', 'video_dir', 'window_position', 'window_monitor', 'window_size', 'write_all', - 'enable_wireframe', 'force_window'] + 'enable_wireframe'] Accessing CLI command options diff --git a/docs/source/installation/docker.rst b/docs/source/installation/docker.rst index c374b08537..67f05eb595 100644 --- a/docs/source/installation/docker.rst +++ b/docs/source/installation/docker.rst @@ -14,9 +14,9 @@ For our image ``manimcommunity/manim``, there are the following tags: .. note:: - When using Manim's CLI within a Docker container, some flags like - ``-p`` (preview file) and ``-f`` (show output file in the file browser) - are not supported. + When using Manim's CLI within a Docker container, options that launch host + applications, such as ``-p`` and ``--show_in_file_browser``, are not + supported. Live preview also requires explicit display forwarding. .. note:: diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index 3c041b76f8..1e07520b7c 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -292,9 +292,14 @@ prototyping and testing. The other options that specify render quality are (1920x1080 60FPS), 2k (2560x1440 60FPS) and 4k quality (3840x2160 60FPS), respectively. -The ``-p`` flag plays the animation once it is rendered. If you want to open -the file browser at the location of the animation instead of playing it, you -can use the ``-f`` flag. You can also omit these two flags. +The ``-p`` flag plays the animation once it is rendered. If you want to open +the file browser at the location of the animation instead, use +``--show_in_file_browser``. You can also omit both options. + +The separate ``-l`` (or ``--live-preview``) option asks a capable renderer to +display frames while rendering. The OpenGL renderer supports this mode. Live +preview with the default ``--format=auto`` does not write a media file; pass a +concrete format such as ``--format=mp4`` to display and record simultaneously. Finally, by default manim will output .mp4 files. If you want your animations in .gif format instead, use the ``--format gif`` flag. The output files will From 56eceb8ea941fc8151ed51b86b1d62fd7beec455 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 14:45:34 +0200 Subject: [PATCH 05/14] Address renderer capability review feedback --- manim/renderer/cairo_renderer.py | 6 +++++- manim/renderer/opengl_renderer.py | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index 39f98a4599..8a4701cfc4 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -35,7 +35,11 @@ class CairoRenderer: Time elapsed since initialisation of scene. """ - capabilities = RendererCapabilities() + capabilities = RendererCapabilities( + live_preview=False, + live_preview_with_output=False, + interactive_embed=False, + ) def __init__( self, diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index 054dfec389..7f1d91301b 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -957,7 +957,13 @@ def update_frame(self, scene: Scene) -> None: self.animation_elapsed_time = time.time() - self.animation_start_time def scene_finished(self, scene: Scene) -> None: - """Finalize configured output for the scene.""" + """Finalize configured output for the scene. + + Parameters + ---------- + scene + The scene that has finished rendering. + """ output = self.file_writer.output_spec if self.num_plays > 0 and (output.is_video or output.is_image_sequence): self.file_writer.finish() From 2251e9d2ca1f0b68d77b514ecfe20c0fcdd6753a Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 15:26:35 +0200 Subject: [PATCH 06/14] Resolve render session configuration once --- manim/_config/output.py | 38 +------------- manim/_config/render_session.py | 27 +++++++--- manim/manager.py | 12 ++--- manim/renderer/cairo_renderer.py | 17 ++----- manim/renderer/opengl_renderer.py | 23 +++------ manim/renderer/protocol.py | 2 - manim/scene/scene.py | 10 +++- manim/scene/scene_file_writer.py | 6 +-- tests/opengl/test_config_opengl.py | 17 +++++-- tests/test_config.py | 50 +++++++++---------- .../test_scene_rendering/test_file_writer.py | 7 ++- .../test_parallel_encoding.py | 41 +++++++++------ 12 files changed, 114 insertions(+), 136 deletions(-) diff --git a/manim/_config/output.py b/manim/_config/output.py index 60948eed31..0eb5bc3d3f 100644 --- a/manim/_config/output.py +++ b/manim/_config/output.py @@ -2,11 +2,10 @@ from __future__ import annotations -__all__ = ["OutputFormat", "OutputSpec", "resolve_output_spec"] +__all__ = ["OutputFormat", "OutputSpec"] from dataclasses import dataclass from enum import StrEnum -from typing import Protocol class OutputFormat(StrEnum): @@ -107,38 +106,3 @@ def segment_extension(self) -> str: extension = self.artifact_extension assert extension is not None return extension - - -class _OutputConfigSource(Protocol): - format: str | OutputFormat | None - save_last_frame: bool - save_sections: bool - transparent: bool - live_preview: bool - enable_gui: bool - dry_run: bool - - -def resolve_output_spec(config: _OutputConfigSource) -> OutputSpec: - """Resolve mutable compatibility configuration into immutable output intent.""" - if config.dry_run: - return OutputSpec( - format=OutputFormat.NONE, - transparent=config.transparent, - save_sections=False, - ) - - requested = OutputFormat.parse(config.format) - if config.save_last_frame: - requested = OutputFormat.PNG - elif requested is OutputFormat.AUTO: - if config.live_preview or config.enable_gui: - requested = OutputFormat.NONE - else: - requested = OutputFormat.MOV if config.transparent else OutputFormat.MP4 - - return OutputSpec( - format=requested, - transparent=config.transparent, - save_sections=config.save_sections, - ) diff --git a/manim/_config/render_session.py b/manim/_config/render_session.py index 7f0190563f..23ff7f623d 100644 --- a/manim/_config/render_session.py +++ b/manim/_config/render_session.py @@ -13,7 +13,7 @@ from manim.renderer.protocol import RendererCapabilities -from .output import OutputFormat, OutputSpec, resolve_output_spec +from .output import OutputFormat, OutputSpec @dataclass(frozen=True, slots=True) @@ -35,7 +35,6 @@ class RenderSessionSpec: class _SessionConfigSource(Protocol): format: str | OutputFormat | None - save_last_frame: bool save_sections: bool transparent: bool preview: bool @@ -52,8 +51,26 @@ def resolve_render_session( renderer_name: str, ) -> RenderSessionSpec: """Resolve and validate one renderer-independent session request.""" - output = resolve_output_spec(config) live_preview = config.live_preview or config.enable_gui + requested_format = OutputFormat.parse(config.format) + if config.dry_run: + requested_format = OutputFormat.NONE + save_sections = False + else: + save_sections = config.save_sections + if requested_format is OutputFormat.AUTO: + if live_preview: + requested_format = OutputFormat.NONE + else: + requested_format = ( + OutputFormat.MOV if config.transparent else OutputFormat.MP4 + ) + + output = OutputSpec( + format=requested_format, + transparent=config.transparent, + save_sections=save_sections, + ) presentation = PresentationSpec( open_after_render=config.preview, live_preview=live_preview, @@ -71,10 +88,6 @@ def resolve_render_session( raise ValueError( "Live preview cannot be combined with final-state-only PNG output.", ) - if live_preview and output.enabled and not capabilities.live_preview_with_output: - raise ValueError( - f"{renderer_name} cannot produce media output while live preview is active.", - ) if presentation.open_after_render and not output.enabled: raise ValueError( "--preview requires a media artifact. Choose a concrete --format when " diff --git a/manim/manager.py b/manim/manager.py index 215a1a21a0..e6444c1c43 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -8,13 +8,13 @@ import srt from . import logger -from ._config.render_session import PresentationSpec, RenderSessionSpec from .scene.section import DefaultSectionType from .utils.exceptions import EndSceneEarlyException, RerunSceneException from .utils.file_ops import open_media_file if TYPE_CHECKING: from ._config.output import OutputSpec + from ._config.render_session import RenderSessionSpec from .animation.animation import Animation from .camera.camera import Camera from .mobject.mobject import Mobject, _AnimationBuilder @@ -90,18 +90,12 @@ def file_writer(self) -> SceneFileWriter: @property def output_spec(self) -> OutputSpec: """Return the immutable output intent captured for this session.""" - return self.file_writer.output_spec + return self.session_spec.output @property def session_spec(self) -> RenderSessionSpec: """Return the immutable output and presentation intent for this session.""" - session_spec = getattr(self.renderer, "session_spec", None) - if isinstance(session_spec, RenderSessionSpec): - return session_spec - return RenderSessionSpec( - output=self.output_spec, - presentation=PresentationSpec(False, False, False), - ) + return self.scene.session_spec @property def time(self) -> float: diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index 8a4701cfc4..c015b2383f 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -6,7 +6,6 @@ from manim.utils.hashing import get_hash_from_play_call from .. import config, logger -from .._config.render_session import resolve_render_session from ..camera.camera import Camera from ..mobject.mobject import Mobject, _AnimationBuilder from ..scene.scene_file_writer import SceneFileWriter @@ -15,6 +14,7 @@ from .protocol import RendererCapabilities if TYPE_CHECKING: + from manim._config.render_session import RenderSessionSpec from manim.animation.animation import Animation from manim.scene.scene import Scene @@ -35,11 +35,7 @@ class CairoRenderer: Time elapsed since initialisation of scene. """ - capabilities = RendererCapabilities( - live_preview=False, - live_preview_with_output=False, - interactive_embed=False, - ) + capabilities = RendererCapabilities(live_preview=False) def __init__( self, @@ -61,16 +57,11 @@ def __init__( self.time = 0.0 self.static_image: PixelArray | None = None - def init_scene(self, scene: Scene) -> None: - self.session_spec = resolve_render_session( - config, - self.capabilities, - renderer_name=type(self).__name__, - ) + def init_scene(self, scene: Scene, session_spec: RenderSessionSpec) -> None: self.file_writer: Any = self._file_writer_class( self, scene.__class__.__name__, - output_spec=self.session_spec.output, + output_spec=session_spec.output, ) def play( diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index 7f1d91301b..7196b9e879 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -14,7 +14,6 @@ from typing_extensions import override from manim import config -from manim._config.render_session import resolve_render_session from manim.mobject.opengl.opengl_mobject import ( OpenGLMobject, OpenGLPoint, @@ -48,6 +47,7 @@ from collections.abc import Iterable from typing import Self + from manim._config.render_session import RenderSessionSpec from manim.animation.animation import Animation from manim.mobject.mobject import Mobject, _AnimationBuilder from manim.scene.scene import Scene @@ -486,11 +486,7 @@ class OpenGLRenderer: The window used for previewing, if any. """ - capabilities = RendererCapabilities( - live_preview=True, - live_preview_with_output=True, - interactive_embed=True, - ) + capabilities = RendererCapabilities(live_preview=True) def __init__( self, @@ -524,7 +520,7 @@ def __init__( self.path_to_texture_id: dict[str, int] = {} self.background_color = config["background_color"] - def init_scene(self, scene: Scene) -> None: + def init_scene(self, scene: Scene, session_spec: RenderSessionSpec) -> None: """ Initializes the OpenGL rendering context and related resources for the given scene. @@ -541,20 +537,15 @@ def init_scene(self, scene: Scene) -> None: The scene to be rendered """ self.partial_movie_files: list[str | None] = [] - self.session_spec = resolve_render_session( - config, - self.capabilities, - renderer_name=type(self).__name__, - ) self.file_writer: SceneFileWriter = self._file_writer_class( self, scene.__class__.__name__, - output_spec=self.session_spec.output, + output_spec=session_spec.output, ) self.scene = scene self.background_color = config["background_color"] - if self.should_create_window(): + if self.should_create_window(session_spec): from .opengl_renderer_window import Window self.window = Window(self) @@ -580,13 +571,13 @@ def init_scene(self, scene: Scene) -> None: moderngl.ONE, ) - def should_create_window(self) -> bool: + def should_create_window(self, session_spec: RenderSessionSpec) -> bool: """ Determine whether a window should be created for rendering based on the current configuration. """ - return self.session_spec.presentation.live_preview + return session_spec.presentation.live_preview def get_pixel_shape(self) -> tuple[int, int] | None: """ diff --git a/manim/renderer/protocol.py b/manim/renderer/protocol.py index b1916dc288..87a0d7ebf7 100644 --- a/manim/renderer/protocol.py +++ b/manim/renderer/protocol.py @@ -12,5 +12,3 @@ class RendererCapabilities: """Optional session features implemented by a renderer.""" live_preview: bool = False - live_preview_with_output: bool = False - interactive_embed: bool = False diff --git a/manim/scene/scene.py b/manim/scene/scene.py index e46a303b70..e20984400d 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -43,6 +43,7 @@ from manim.mobject.opengl.opengl_mobject import OpenGLPoint from .. import config, logger +from .._config.render_session import resolve_render_session from ..animation.animation import Animation, Wait, prepare_animation from ..camera.camera import Camera from ..constants import * @@ -213,7 +214,12 @@ def __init__( ) else: self.renderer = renderer - self.renderer.init_scene(self) + self.session_spec = resolve_render_session( + config, + self.renderer.capabilities, + renderer_name=type(self.renderer).__name__, + ) + self.renderer.init_scene(self, self.session_spec) self.mobjects: list[Mobject] = [] # TODO, remove need for foreground mobjects @@ -1545,7 +1551,7 @@ def interact(self, shell: Any, keyboard_thread: threading.Thread) -> None: def embed(self) -> None: assert isinstance(self.renderer, OpenGLRenderer) - if not self.renderer.session_spec.presentation.live_preview: + if not self.session_spec.presentation.live_preview: logger.warning("Called embed() while no live preview window is available.") return if self.renderer.file_writer.output_spec.enabled: diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index 41c5d557df..318f003fb0 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -35,7 +35,7 @@ from .. import config, logger from .._config.logger_utils import set_file_logger -from .._config.output import OutputSpec, resolve_output_spec +from .._config.output import OutputSpec from ..constants import RendererType from ..utils.file_ops import ( add_extension_if_not_present, @@ -225,11 +225,11 @@ def __init__( self, renderer: CairoRenderer | OpenGLRenderer, scene_name: str, - output_spec: OutputSpec | None = None, + output_spec: OutputSpec, **kwargs: Any, ) -> None: self.renderer = renderer - self.output_spec = output_spec or resolve_output_spec(config) + self.output_spec = output_spec self._inflight_encode_jobs: list[_PartialMovieEncodeJob] = [] self._inflight_by_path: dict[str, _PartialMovieEncodeJob] = {} self._current_encode_job: _PartialMovieEncodeJob | None = None diff --git a/tests/opengl/test_config_opengl.py b/tests/opengl/test_config_opengl.py index b08721f64b..0250502ea6 100644 --- a/tests/opengl/test_config_opengl.py +++ b/tests/opengl/test_config_opengl.py @@ -6,7 +6,16 @@ import numpy as np from manim import WHITE, Scene, Square, tempconfig -from manim._config.output import resolve_output_spec +from manim._config.render_session import resolve_render_session +from manim.renderer.protocol import RendererCapabilities + + +def _resolve_output(config): + return resolve_render_session( + config, + RendererCapabilities(live_preview=True), + renderer_name="TestRenderer", + ).output def test_tempconfig(config, using_opengl_renderer): @@ -113,12 +122,12 @@ def test_frame_size_if_frame_width(config, using_opengl_renderer, tmp_path): def test_temporary_dry_run(config, using_opengl_renderer): """Test that tempconfig correctly restores after setting dry_run.""" - assert resolve_output_spec(config).is_video + assert _resolve_output(config).is_video with tempconfig({"dry_run": True}): - assert not resolve_output_spec(config).enabled + assert not _resolve_output(config).enabled - assert resolve_output_spec(config).is_video + assert _resolve_output(config).is_video def test_dry_run_with_png_format(config, using_opengl_renderer, dry_run): diff --git a/tests/test_config.py b/tests/test_config.py index 63b9d97301..55272c8c8f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,7 @@ from click.testing import CliRunner from manim import RIGHT, WHITE, Scene, Square, Tex, Text, Vector, tempconfig -from manim._config.output import OutputFormat, resolve_output_spec +from manim._config.output import OutputFormat from manim._config.render_session import resolve_render_session from manim._config.utils import ManimConfig from manim.cli.render.commands import render @@ -19,6 +19,14 @@ from tests.assert_utils import assert_dir_exists, assert_dir_filled, assert_file_exists +def _resolve_output(config): + return resolve_render_session( + config, + RendererCapabilities(live_preview=True), + renderer_name="TestRenderer", + ).output + + def test_tempconfig(config): """Test the tempconfig context manager.""" original = config.copy() @@ -64,7 +72,7 @@ def test_tempconfig_restores_renderer_class_bases(config): ) def test_resolve_segment_extensions(config, format, expected_file_extension): config.format = format - assert resolve_output_spec(config).segment_extension == expected_file_extension + assert _resolve_output(config).segment_extension == expected_file_extension @pytest.mark.parametrize( @@ -77,24 +85,24 @@ def test_resolve_segment_extensions(config, format, expected_file_extension): ("gif", OutputFormat.GIF), ], ) -def test_resolve_output_spec(config, format, expected_format): +def test_resolve_session_output(config, format, expected_format): config.format = format - assert resolve_output_spec(config).format is expected_format + assert _resolve_output(config).format is expected_format def test_transparent_auto_output_resolves_to_mov(config): config.format = "auto" config.transparent = True - assert resolve_output_spec(config).format is OutputFormat.MOV + assert _resolve_output(config).format is OutputFormat.MOV def test_live_preview_auto_output_resolves_to_none(config): config.format = "auto" config.live_preview = True - assert resolve_output_spec(config).format is OutputFormat.NONE + assert _resolve_output(config).format is OutputFormat.NONE def test_live_preview_requires_renderer_capability(config): @@ -120,31 +128,19 @@ def test_preview_requires_output(config): ) -def test_live_preview_with_output_requires_renderer_capability(config): - config.format = "mp4" - config.live_preview = True - - with pytest.raises(ValueError, match="cannot produce media output"): - resolve_render_session( - config, - RendererCapabilities(live_preview=True), - renderer_name="TestRenderer", - ) - - def test_explicit_transparent_mp4_is_rejected(config): config.format = "mp4" config.transparent = True with pytest.raises(ValueError, match="does not support an alpha channel"): - resolve_output_spec(config) + _resolve_output(config) def test_dry_run_resolves_no_output_without_mutating_output_request(config): config.format = "gif" config.dry_run = True - assert resolve_output_spec(config).format is OutputFormat.NONE + assert _resolve_output(config).format is OutputFormat.NONE assert config.format == "gif" @@ -152,7 +148,7 @@ def test_save_last_frame_resolves_to_still_output(config): config.format = "auto" config.save_last_frame = True - assert resolve_output_spec(config).format is OutputFormat.PNG + assert _resolve_output(config).format is OutputFormat.PNG def test_save_last_frame_alias_works_with_tempconfig(config): @@ -160,7 +156,7 @@ def test_save_last_frame_alias_works_with_tempconfig(config): with tempconfig({"save_last_frame": True}): assert config.format == "png" - assert resolve_output_spec(config).is_still + assert _resolve_output(config).is_still assert config.format == original_format @@ -170,7 +166,7 @@ def test_sections_require_video_output(config): config.save_sections = True with pytest.raises(ValueError, match="Section output requires"): - resolve_output_spec(config) + _resolve_output(config) def test_format_is_loaded_from_config_file(tmp_path, config): @@ -212,7 +208,7 @@ def test_opengl_cli_no_longer_disables_automatic_output(tmp_path, config): assert config.renderer is RendererType.OPENGL assert config.format == "auto" - assert resolve_output_spec(config).format is OutputFormat.MP4 + assert _resolve_output(config).format is OutputFormat.MP4 def test_absent_cli_output_options_preserve_config_file_values(tmp_path): @@ -390,12 +386,12 @@ def test_frame_size(tmp_path, config): def test_temporary_dry_run(config): """Test that tempconfig correctly restores after setting dry_run.""" - assert resolve_output_spec(config).is_video + assert _resolve_output(config).is_video with tempconfig({"dry_run": True}): - assert not resolve_output_spec(config).enabled + assert not _resolve_output(config).enabled - assert resolve_output_spec(config).is_video + assert _resolve_output(config).is_video def test_dry_run_with_png_format(config, dry_run): diff --git a/tests/test_scene_rendering/test_file_writer.py b/tests/test_scene_rendering/test_file_writer.py index e8cd2ce3d0..4c94509b1f 100644 --- a/tests/test_scene_rendering/test_file_writer.py +++ b/tests/test_scene_rendering/test_file_writer.py @@ -8,6 +8,7 @@ import pytest from manim import DR, Circle, Create, Scene, Star, tempconfig +from manim._config.output import OutputFormat, OutputSpec from manim.scene.scene_file_writer import SceneFileWriter, to_av_frame_rate from manim.utils.commands import capture, get_video_metadata @@ -191,7 +192,11 @@ def test_frame_rates(): def _new_file_writer(scene_name: str) -> SceneFileWriter: renderer = Mock() renderer.num_plays = 0 - return SceneFileWriter(renderer, scene_name) + return SceneFileWriter( + renderer, + scene_name, + OutputSpec(OutputFormat.MP4, transparent=False, save_sections=False), + ) def test_clean_cache_ignores_hidden_files(config, tmp_path): diff --git a/tests/test_scene_rendering/test_parallel_encoding.py b/tests/test_scene_rendering/test_parallel_encoding.py index a9cedebf47..fa58a7671b 100644 --- a/tests/test_scene_rendering/test_parallel_encoding.py +++ b/tests/test_scene_rendering/test_parallel_encoding.py @@ -15,10 +15,21 @@ from manim import FadeIn, Scene, Square, capture, tempconfig from manim._config import config +from manim._config.output import OutputFormat, OutputSpec from manim.cli.render.commands import render from manim.utils.exceptions import RerunSceneException _ENCODER_THREAD_PREFIX = "partial-movie-encoder-" +_VIDEO_OUTPUT = OutputSpec( + OutputFormat.MP4, + transparent=False, + save_sections=False, +) +_NO_OUTPUT = OutputSpec( + OutputFormat.NONE, + transparent=False, + save_sections=False, +) _UNIQUE_PLAYS = 6 _TOTAL_PLAYS = _UNIQUE_PLAYS + 2 @@ -343,7 +354,7 @@ def encode(*args): config.media_dir = str(tmp_path) renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "FailFastScene") + writer = SceneFileWriter(renderer, "FailFastScene", _VIDEO_OUTPUT) job = _new_encode_job(tmp_path, monkeypatch, "fail_fast", stream, container) job.path.write_bytes(b"stale") writer._current_encode_job = job @@ -389,7 +400,7 @@ def test_frame_queue_configuration( config.encoder_queue_size = encoder_queue_size renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "FrameQueueSizeScene") + writer = SceneFileWriter(renderer, "FrameQueueSizeScene", _VIDEO_OUTPUT) writer.open_partial_movie_stream(tmp_path / "partial.mp4") job = writer._current_encode_job @@ -412,7 +423,7 @@ def test_close_partial_movie_stream_respects_cap_and_joins_fifo( config.max_inflight_encoders = max_inflight_encoders renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "EncoderCapScene") + writer = SceneFileWriter(renderer, "EncoderCapScene", _VIDEO_OUTPUT) jobs = [Mock(path=tmp_path / f"partial_{index}.mp4") for index in range(3)] for index, job in enumerate(jobs): @@ -450,7 +461,7 @@ def test_cap_join_failure_drains_all_inflight_jobs(config, tmp_path): config.max_inflight_encoders = 3 renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "EncoderCapFailureScene") + writer = SceneFileWriter(renderer, "EncoderCapFailureScene", _VIDEO_OUTPUT) jobs = [Mock(path=tmp_path / f"partial_{index}.mp4") for index in range(3)] jobs[0].join.side_effect = primary_exception jobs[1].join.side_effect = secondary_exception @@ -477,7 +488,7 @@ def test_is_already_cached_joins_same_path_inflight_job(config, tmp_path): renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedInflightScene") + writer = SceneFileWriter(renderer, "CachedInflightScene", _VIDEO_OUTPUT) hash_invocation = "same_path_hash" path = ( writer.partial_movie_directory @@ -500,7 +511,7 @@ def test_same_path_join_failure_drains_unrelated_jobs(config, tmp_path): expected_exception = RuntimeError("same-path join failed") renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedInflightFailureScene") + writer = SceneFileWriter(renderer, "CachedInflightFailureScene", _VIDEO_OUTPUT) hash_invocation = "failing_same_path_hash" path = ( writer.partial_movie_directory @@ -528,7 +539,7 @@ def test_open_partial_movie_stream_joins_same_path_inflight_job(config, tmp_path renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "OpenInflightScene") + writer = SceneFileWriter(renderer, "OpenInflightScene", _VIDEO_OUTPUT) path = tmp_path / "same_path.mp4" inflight_job = Mock(path=path) writer._inflight_encode_jobs.append(inflight_job) @@ -559,7 +570,7 @@ def test_finish_propagates_join_failure_and_clears_inflight_state( expected_exception = RuntimeError("join failed") renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "JoinFailureScene") + writer = SceneFileWriter(renderer, "JoinFailureScene", _VIDEO_OUTPUT) failing_job = Mock(path=tmp_path / "failing.mp4") failing_job.join.side_effect = expected_exception succeeding_job = Mock(path=tmp_path / "succeeding.mp4") @@ -586,7 +597,7 @@ def _new_writer(config, tmp_path, scene_name): config.media_dir = str(tmp_path) renderer = Mock() renderer.num_plays = 0 - return SceneFileWriter(renderer, scene_name) + return SceneFileWriter(renderer, scene_name, _VIDEO_OUTPUT) def _healthy_current_job(tmp_path, monkeypatch, name): @@ -676,7 +687,7 @@ def test_abort_encode_jobs_noop_on_dry_run_writer(config): with tempconfig({"dry_run": True}): renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "DryRunAbortScene") + writer = SceneFileWriter(renderer, "DryRunAbortScene", _NO_OUTPUT) writer.abort_encode_jobs() writer.abort_encode_jobs(reraise_encoder_failures=True) @@ -1017,7 +1028,7 @@ def test_is_already_cached_false_after_joining_failed_path(config, tmp_path): with tempconfig({"media_dir": str(tmp_path)}): renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedReturnScene") + writer = SceneFileWriter(renderer, "CachedReturnScene", _VIDEO_OUTPUT) hash_invocation = "missing_partial_hash" path = ( writer.partial_movie_directory @@ -1037,7 +1048,7 @@ def test_is_already_cached_true_when_partial_exists(config, tmp_path): with tempconfig({"media_dir": str(tmp_path)}): renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedReturnScene") + writer = SceneFileWriter(renderer, "CachedReturnScene", _VIDEO_OUTPUT) hash_invocation = "present_partial_hash" path = ( writer.partial_movie_directory @@ -1054,7 +1065,7 @@ def test_close_partial_movie_stream_without_open_stream_raises(config, tmp_path) with tempconfig({"media_dir": str(tmp_path)}): renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "GuardScene") + writer = SceneFileWriter(renderer, "GuardScene", _VIDEO_OUTPUT) with pytest.raises(RuntimeError, match="without an open partial"): writer.close_partial_movie_stream() @@ -1066,7 +1077,7 @@ def test_open_partial_movie_stream_without_path_raises(config, tmp_path): with tempconfig({"media_dir": str(tmp_path)}): renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "GuardScene") + writer = SceneFileWriter(renderer, "GuardScene", _VIDEO_OUTPUT) writer.partial_movie_files = [None] with pytest.raises(RuntimeError, match="partial movie file path"): @@ -1084,7 +1095,7 @@ def test_write_frame_without_open_stream_drops_frame(config, tmp_path): with tempconfig({"media_dir": str(tmp_path)}): renderer = Mock() renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "DropFrameScene") + writer = SceneFileWriter(renderer, "DropFrameScene", _VIDEO_OUTPUT) assert writer._current_encode_job is None # Must not raise and must not create a job. From 5e128191694fed6b9b7aa094a8cad49305e9086f Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 15:37:21 +0200 Subject: [PATCH 07/14] Update output and render-flow documentation --- docs/source/contributing/testing.rst | 8 +- docs/source/guides/configuration.rst | 27 +++---- docs/source/guides/deep_dive.rst | 85 ++++++++++++++------- docs/source/tutorials/output_and_config.rst | 65 ++++++++++++---- 4 files changed, 120 insertions(+), 65 deletions(-) diff --git a/docs/source/contributing/testing.rst b/docs/source/contributing/testing.rst index dfc6a225f8..e8d58f5496 100644 --- a/docs/source/contributing/testing.rst +++ b/docs/source/contributing/testing.rst @@ -292,10 +292,10 @@ Note the fixtures here: You have to generate a ``.json`` file first to be able to test your video. To do that, use ``helpers.save_control_data_from_video``. -For instance, a test that will check if the l flag works properly will first -require rendering a video using the -l flag from a scene. Then we will test -(in this case, SquareToCircle), that lives in -``test_scene_rendering/simple_scene.py``. Change directories to ``tests/``, +For instance, a test that checks low-quality rendering first requires rendering +a video from a scene with the ``-ql`` flag. The example below tests +``SquareToCircle``, which lives in ``test_scene_rendering/simple_scene.py``. +Change directories to ``tests/``, create a file (e.g. ``create\_data.py``) that you will remove as soon as you're done. Then run: diff --git a/docs/source/guides/configuration.rst b/docs/source/guides/configuration.rst index cdf6f603c9..dafd1a3e75 100644 --- a/docs/source/guides/configuration.rst +++ b/docs/source/guides/configuration.rst @@ -357,26 +357,17 @@ highest precedence is: 5. any programmatic changes made after the config system is set. -A list of all config options -**************************** +Inspecting available config options +*********************************** -.. code:: +Run ``manim cfg show`` to inspect the currently resolved configuration and +``manim render --help`` for the authoritative list of render CLI options. The +attributes available for programmatic configuration are documented on +:class:`.ManimConfig`. - ['aspect_ratio', 'assets_dir', 'background_color', 'background_opacity', - 'bottom', 'custom_folders', 'disable_caching', 'dry_run', - 'encoder_queue_size', 'ffmpeg_loglevel', 'flush_cache', 'frame_height', 'frame_rate', - 'frame_size', 'frame_width', 'frame_x_radius', 'frame_y_radius', - 'from_animation_number', `fullscreen`, 'images_dir', 'input_file', 'left_side', - 'log_dir', 'log_to_file', 'max_files_cached', 'max_inflight_encoders', - 'media_dir', 'media_width', 'notify_outdated_version', 'output_file', - 'partial_movie_dir', - 'pixel_height', 'pixel_width', 'plugins', 'preview', 'live_preview', - 'progress_bar', 'quality', 'right_side', 'save_last_frame', 'scene_names', - 'show_in_file_browser', 'sound', 'tex_dir', - 'tex_template', 'tex_template_file', 'text_dir', 'top', 'transparent', - 'upto_animation_number', 'use_opengl_renderer', 'verbosity', 'video_dir', - 'window_position', 'window_monitor', 'window_size', 'write_all', - 'enable_wireframe'] +Some CLI conveniences intentionally map to a canonical configuration value. In +particular, ``-s`` / ``--save_last_frame`` sets ``format = png``; configuration +files should use the canonical ``format`` option directly. Accessing CLI command options diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index 050eb052d5..bd404e78af 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -119,10 +119,9 @@ Preliminaries Importing the library ^^^^^^^^^^^^^^^^^^^^^ -Independent of how exactly you are telling your system -to render the scene, i.e., whether you run ``manim -qm -p file_name.py ToyExample``, or -whether you are rendering the scene directly from the Python script via a snippet -like +You can ask Manim to render in several ways. For example, you can run +``manim -qm -p file_name.py ToyExample`` or render directly from a Python script +with a snippet like :: @@ -130,8 +129,14 @@ like scene = ToyExample() scene.render() -or whether you are rendering the code in a Jupyter notebook, you are still telling your -python interpreter to import the library. The usual pattern used to do this is +In this example, ``preview=True`` means that the completed artifact is opened +after rendering. It does not request a live render window; that is configured +separately with ``live_preview=True`` and requires a renderer that advertises +live-preview support. + +Whether you are rendering this way or from a Jupyter notebook, you are still +telling your Python interpreter to import the library. The usual pattern used to +do this is :: @@ -243,7 +248,7 @@ then calls the scene's render method in the **Jupyter notebooks.** In Jupyter notebooks, the communication with the library is handled by the ``%%manim`` magic command, which is implemented in the ``manim.utils.ipython_magic`` module. There is -:meth:`some documentation <.ManimMagic.manim>` available for the magic command, +:meth:`some documentation <.ManimMagic.manim>` available for the magic command. The implementation instantiates the requested scene and calls its render method; the scene attaches a manager lazily through that entry point. @@ -263,18 +268,36 @@ depend on any configuration options set in ``config``. Then the scene inspects t ``config.renderer``, and based on its value, either instantiates a ``CairoRenderer`` or an ``OpenGLRenderer`` object and assigns it to its ``renderer`` attribute. -The scene then asks its renderer to initialize the scene by calling +After selecting the renderer, the scene resolves the mutable configuration into +one immutable render-session specification. In abbreviated form, initialization +continues as follows: :: - self.renderer.init_scene(self) - -Inspecting both the default Cairo renderer and the OpenGL renderer shows that the ``init_scene`` -method effectively makes the renderer instantiate a :class:`.SceneFileWriter` object, which -basically is Manim's interface to ``libav`` (FFMPEG) and actually writes the movie file. The Cairo -renderer (see the implementation `here `__) does not require any further initialization. The OpenGL renderer -does some additional setup to enable the realtime rendering preview window, which we do not go -into detail further here. + self.session_spec = resolve_render_session( + config, + self.renderer.capabilities, + renderer_name=type(self.renderer).__name__, + ) + self.renderer.init_scene(self, self.session_spec) + +The session specification separates primary artifact intent (an ``OutputSpec``) +from presentation requests such as opening the completed artifact or displaying +a live preview. Resolution also validates requests against the selected +renderer's capabilities. For example, Cairo rejects live preview, while OpenGL +advertises support for it. With ``format = auto``, requesting live preview +selects no file output; a concrete format records the render as well. + +Inspecting the initialization methods of both renderers shows that they +instantiate a :class:`.SceneFileWriter`. The writer must receive the already +resolved ``OutputSpec``; it does not reinterpret the global configuration to +decide whether or what to write. It remains Manim's interface to ``libav`` for +encoding media. The Cairo renderer (see the implementation `here +`__) +does not require further renderer-specific initialization. OpenGL creates a +window only when the resolved presentation specification requests live preview. +The ``-p`` / ``--preview`` option does not create this window; it opens the +completed artifact after rendering. After the renderer has been instantiated and initialized its file writer, the scene populates further initial attributes (notable mention: the ``mobjects`` attribute @@ -284,9 +307,11 @@ attribute is initially ``None`` unless the caller attaches a manager explicitly. .. warning:: :class:`.Manager` is an incremental coordination boundary. At this stage the - renderer still owns its camera, clock, play count, skip state, and file writer; - the manager exposes forwarding views of them. The scene and renderer therefore - still have substantial interplay that later refactors aim to remove. + scene captures the immutable session specification before renderer + initialization, while the renderer still owns its camera, clock, play count, + skip state, and file writer. The manager exposes the session and forwarding + views of renderer state. The scene and renderer therefore still have + substantial interplay that later refactors aim to remove. The rest of this article is concerned with the last line in our toy example script:: @@ -314,11 +339,15 @@ The first three call the corresponding customizable scene hooks: After these hooks have run, :meth:`.Manager.post_construct` asks the renderer to finish the scene. For Cairo this calls :meth:`.CairoRenderer.scene_finished`, -which checks whether animations have been played and tells the -:class:`.SceneFileWriter` to finish the output. For video output, the file writer -waits for partial movie files that are still being encoded and combines them into -the final movie. If no animations have been played, Manim assumes that a static -image should be output. +which checks the resolved output intent and tells the :class:`.SceneFileWriter` +to finish time-based output. For video output, the file writer waits for partial +movie files that are still being encoded and combines them into the final movie. +Final-state PNG output skips intermediate animation frames. A video request for +a scene without any play calls produces a useful still image instead of an empty +movie. The writer records the completed artifact as ``final_file_path`` without +replacing the configured ``output_file`` value. After finalization, the manager +uses the presentation specification to open the artifact or reveal it in the +file browser when requested. **Back in our toy example,** the call to :meth:`.Scene.render` creates a manager, then :meth:`.Manager.render` triggers :meth:`.Scene.setup` (which only consists of @@ -1038,9 +1067,11 @@ calls the scene's cleanup method :meth:`.Scene.tear_down`, followed by scene, and the renderer in turn asks its scene file writer to wrap things up by calling :meth:`.SceneFileWriter.finish`. The file writer first waits for all remaining encoding jobs, then combines the completed partial movie files into the -final product. If rendering aborts during a play instead, the manager asks the file -writer to abort its encoding jobs; the incomplete current partial movie file is -removed so that it cannot be mistaken for a valid cached result on a later render. +final product and records its path. If rendering aborts during a play instead, the +manager asks the file writer to abort its encoding jobs; the incomplete current +partial movie file is removed so that it cannot be mistaken for a valid cached +result on a later render. Once output is finalized, the manager carries out any +post-render presentation request from the immutable session specification. And there you go! This is a more or less detailed description of how Manim works under the hood. While we did not discuss every single line of code in detail diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index 1e07520b7c..46491faea8 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -37,7 +37,8 @@ files and the project folder will look as follows. | └─480p15 | ├─SquareToCircle.mp4 | └─partial_movie_files - ├─text + | └─SquareToCircle + ├─texts └─Tex @@ -45,12 +46,13 @@ There are quite a few new files. The main output is in ``media/videos/scene/480p15/SquareToCircle.mp4``. By default, the ``media`` folder will contain all of manim's output files. The ``media/videos`` subfolder contains the rendered videos. Inside of it, you will find one folder -for each different video quality. In our case, since we used the ``-l`` flag, +for each different video quality. In our case, since we used the ``-ql`` flag, the video was generated at 480 resolution at 15 frames per second from the ``scene.py`` file. Therefore, the output can be found inside ``media/videos/scene/480p15``. The additional folders -``media/videos/scene/480p15/partial_movie_files`` as well as ``media/text`` and -``media/Tex`` contain files that are used by manim internally. +``media/videos/scene/480p15/partial_movie_files/SquareToCircle`` as well as +``media/texts`` and ``media/Tex`` contain files that are used by Manim +internally. You can see how manim makes use of the generated folder structure by executing the following command, @@ -93,16 +95,18 @@ And the folder structure should look as follows. | ├─480p15 | | ├─SquareToCircle.mp4 | | └─partial_movie_files + | | └─SquareToCircle | └─1080p60 | ├─SquareToCircle.mp4 | └─partial_movie_files - ├─text + | └─SquareToCircle + ├─texts └─Tex Manim has created a new folder ``media/videos/1080p60``, which corresponds to -the high resolution and the 60 frames per second. Inside of it, you can find +the high resolution and the 60 frames per second. Inside of it, you can find the new ``SquareToCircle.mp4``, as well as the corresponding -``partial_movie_files``. +``partial_movie_files/SquareToCircle`` directory. When working on a project with multiple scenes, and trying out multiple resolutions, the structure of the output directories will keep all your videos @@ -119,25 +123,54 @@ The corresponding folder structure looks like this: └─media ├─images | └─scene - | ├─SquareToCircle.png + | ├─SquareToCircle_ManimCE_vX.Y.Z.png ├─videos | └─scene | ├─480p15 | | ├─SquareToCircle.mp4 | | └─partial_movie_files + | | └─SquareToCircle | └─1080p60 | ├─SquareToCircle.mp4 | └─partial_movie_files - ├─text + | └─SquareToCircle + ├─texts └─Tex Saving the last frame with ``-s`` can be combined with the flags for different resolutions, e.g. ``-s -ql``, ``-s -qh``. The equivalent ``--format=png`` spelling also selects this fast final-state-only mode. To write every rendered frame as a numbered PNG instead, use -``--format=png-sequence``. - - +``--format=png-sequence``. The sequence is stored in a scene-specific directory, +for example ``media/images/scene/SquareToCircle/0000.png``. + +Output formats +************** + +The ``--format`` option selects one primary artifact: + +.. list-table:: + :header-rows: 1 + + * - Value + - Behavior + * - ``auto`` + - The default. Produces MP4 for opaque output and MOV for transparent + output. When live preview is requested, it produces no file unless a + concrete format is given. + * - ``mp4``, ``mov``, ``webm`` or ``gif`` + - Produces the selected video format. Transparent MP4 is rejected because + that container does not support the required alpha channel. + * - ``png`` + - Fast-forwards animations and writes only the final scene state. This is + equivalent to ``-s``. + * - ``png-sequence`` + - Evaluates the full frame progression and writes every frame as PNG. + * - ``none`` + - Evaluates the scene without producing a primary media artifact. + +``--dry_run`` also suppresses media output, but remains a separate execution +request rather than modifying the configured format. Sections @@ -301,10 +334,10 @@ display frames while rendering. The OpenGL renderer supports this mode. Live preview with the default ``--format=auto`` does not write a media file; pass a concrete format such as ``--format=mp4`` to display and record simultaneously. -Finally, by default manim will output .mp4 files. If you want your animations -in .gif format instead, use the ``--format gif`` flag. The output files will -be in the same folder as the .mp4 files, and with the same name, but a -different file extension. +Finally, by default Manim outputs ``.mp4`` files. To request GIF output instead, +use ``--format=gif``. GIF and final-frame PNG names include the installed Manim +version when no explicit ``--output_file`` is supplied; ``X.Y.Z`` in the example +above stands for that version. This was a quick review of some of the most frequent command-line flags. For a thorough review of all flags available, see the :doc:`thematic guide on From cf18c9be5fe7941c807879ef66e3909e275128e3 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 15:49:42 +0200 Subject: [PATCH 08/14] Keep deep-dive introduction focused --- docs/source/guides/deep_dive.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index bd404e78af..d980a1f141 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -129,11 +129,6 @@ with a snippet like scene = ToyExample() scene.render() -In this example, ``preview=True`` means that the completed artifact is opened -after rendering. It does not request a live render window; that is configured -separately with ``live_preview=True`` and requires a renderer that advertises -live-preview support. - Whether you are rendering this way or from a Jupyter notebook, you are still telling your Python interpreter to import the library. The usual pattern used to do this is From af44d044b7c95747973591b3981b51bbdab876b7 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Wed, 26 Aug 2026 16:56:38 +0200 Subject: [PATCH 09/14] Update slow output tests for normalized formats --- .../opengl/test_cli_flags_opengl.py | 63 ++++++++++++------- tests/test_scene_rendering/test_cli_flags.py | 42 +++++++------ 2 files changed, 63 insertions(+), 42 deletions(-) diff --git a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py index 5cc4fafeeb..de832bf683 100644 --- a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py +++ b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py @@ -166,11 +166,19 @@ def test_image_output_for_static_scene(tmp_path, manim_cfg_file, simple_scenes_p out, err, exit_code = capture(command) assert exit_code == 0, err - exists = (tmp_path / "videos").exists() - assert not exists, "running manim with static scene rendered a video" + unexpected_movie_path = ( + tmp_path / "videos" / "simple_scenes" / "480p15" / "StaticScene.mp4" + ) + assert not unexpected_movie_path.exists(), ( + "running manim with a static scene rendered a video" + ) - is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) - assert not is_empty, "running manim without animations did not render an image" + expected_image_path = add_version_before_extension( + tmp_path / "images" / "simple_scenes" / "StaticScene.png", + ) + assert expected_image_path.exists(), ( + "running manim without animations did not render an image" + ) @pytest.mark.slow @@ -194,17 +202,19 @@ def test_no_image_output_with_interactive_embed( out, err, exit_code = capture(command) assert exit_code == 0, err - exists = (tmp_path / "videos").exists() - assert not exists, "running manim with static scene rendered a video" + unexpected_movie_path = ( + tmp_path / "videos" / "simple_scenes" / "480p15" / "InteractiveStaticScene.mp4" + ) + assert not unexpected_movie_path.exists(), ( + "running an interactive static scene rendered a video" + ) is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) - assert is_empty, ( - "running manim static scene with interactive embed rendered an image" - ) + assert is_empty, "running an interactive static scene rendered an image" @pytest.mark.slow -def test_no_default_image_output_with_non_static_scene( +def test_default_video_output_with_non_static_scene( tmp_path, manim_cfg_file, simple_scenes_path ): scene_name = "SceneWithNonStaticWait" @@ -223,13 +233,15 @@ def test_no_default_image_output_with_non_static_scene( out, err, exit_code = capture(command) assert exit_code == 0, err - exists = (tmp_path / "videos").exists() - assert not exists, "running manim with static scene rendered a video" + expected_movie_path = ( + tmp_path / "videos" / "simple_scenes" / "480p15" / "SceneWithNonStaticWait.mp4" + ) + assert expected_movie_path.exists(), ( + "default output did not render the non-static scene as a video" + ) is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) - assert is_empty, ( - "running manim static scene with interactive embed rendered an image" - ) + assert is_empty, "default video output unexpectedly rendered an image" @pytest.mark.slow @@ -518,12 +530,12 @@ def test_videos_not_created_when_png_format_set( @pytest.mark.slow -def test_images_are_created_when_png_format_set( +def test_images_are_created_when_png_sequence_format_set( tmp_path, manim_cfg_file, simple_scenes_path, ): - """Test images are created in media directory when --format png is set""" + """Test OpenGL images are created when --format png-sequence is set.""" scene_name = "SquareToCircle" command = [ sys.executable, @@ -535,24 +547,26 @@ def test_images_are_created_when_png_format_set( "--media_dir", str(tmp_path), "--format", - "png", + "png-sequence", str(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" + expected_png_path = ( + tmp_path / "images" / "simple_scenes" / "SquareToCircle" / "0000.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( +def test_png_sequence_images_are_zero_padded( tmp_path, manim_cfg_file, simple_scenes_path, ): - """Test images are zero padded when --format png and --zero_pad n are set""" + """Test OpenGL PNG-sequence images respect --zero_pad.""" scene_name = "SquareToCircle" command = [ sys.executable, @@ -564,7 +578,7 @@ def test_images_are_zero_padded_when_zero_pad_set( "--media_dir", str(tmp_path), "--format", - "png", + "png-sequence", "--zero_pad", "3", str(simple_scenes_path), @@ -573,12 +587,13 @@ def test_images_are_zero_padded_when_zero_pad_set( out, err, exit_code = capture(command) assert exit_code == 0, err - unexpected_png_path = tmp_path / "images" / "simple_scenes" / "SquareToCircle0.png" + sequence_dir = tmp_path / "images" / "simple_scenes" / "SquareToCircle" + unexpected_png_path = sequence_dir / "0.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" + expected_png_path = sequence_dir / "000.png" assert expected_png_path.exists(), "png file not found at " + str(expected_png_path) diff --git a/tests/test_scene_rendering/test_cli_flags.py b/tests/test_scene_rendering/test_cli_flags.py index f3d51b567c..f81c87dae2 100644 --- a/tests/test_scene_rendering/test_cli_flags.py +++ b/tests/test_scene_rendering/test_cli_flags.py @@ -491,12 +491,12 @@ def test_videos_not_created_when_png_format_set( @pytest.mark.slow -def test_images_are_created_when_png_format_set( +def test_images_are_created_when_png_sequence_format_set( tmp_path, manim_cfg_file, simple_scenes_path, ): - """Test images are created in media directory when --format png is set""" + """Test images are created when --format png-sequence is set.""" scene_name = "SquareToCircle" command = [ sys.executable, @@ -506,24 +506,26 @@ def test_images_are_created_when_png_format_set( "--media_dir", str(tmp_path), "--format", - "png", + "png-sequence", str(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" + expected_png_path = ( + tmp_path / "images" / "simple_scenes" / "SquareToCircle" / "0000.png" + ) 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( +def test_images_are_created_when_png_sequence_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""" + """Test OpenGL images are created when --format png-sequence is set.""" scene_name = "SquareToCircle" command = [ sys.executable, @@ -535,24 +537,26 @@ def test_images_are_created_when_png_format_set_for_opengl( "--media_dir", str(tmp_path), "--format", - "png", + "png-sequence", str(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" + expected_png_path = ( + tmp_path / "images" / "simple_scenes" / "SquareToCircle" / "0000.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( +def test_png_sequence_images_are_zero_padded( tmp_path, manim_cfg_file, simple_scenes_path, ): - """Test images are zero padded when --format png and --zero_pad n are set""" + """Test PNG-sequence images respect --zero_pad.""" scene_name = "SquareToCircle" command = [ sys.executable, @@ -562,7 +566,7 @@ def test_images_are_zero_padded_when_zero_pad_set( "--media_dir", str(tmp_path), "--format", - "png", + "png-sequence", "--zero_pad", "3", str(simple_scenes_path), @@ -571,22 +575,23 @@ def test_images_are_zero_padded_when_zero_pad_set( out, err, exit_code = capture(command) assert exit_code == 0, err - unexpected_png_path = tmp_path / "images" / "simple_scenes" / "SquareToCircle0.png" + sequence_dir = tmp_path / "images" / "simple_scenes" / "SquareToCircle" + unexpected_png_path = sequence_dir / "0.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" + expected_png_path = sequence_dir / "000.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_for_opengl( +def test_opengl_png_sequence_images_are_zero_padded( 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""" + """Test OpenGL PNG-sequence images respect --zero_pad.""" scene_name = "SquareToCircle" command = [ sys.executable, @@ -598,7 +603,7 @@ def test_images_are_zero_padded_when_zero_pad_set_for_opengl( "--media_dir", str(tmp_path), "--format", - "png", + "png-sequence", "--zero_pad", "3", str(simple_scenes_path), @@ -607,12 +612,13 @@ def test_images_are_zero_padded_when_zero_pad_set_for_opengl( out, err, exit_code = capture(command) assert exit_code == 0, err - unexpected_png_path = tmp_path / "images" / "simple_scenes" / "SquareToCircle0.png" + sequence_dir = tmp_path / "images" / "simple_scenes" / "SquareToCircle" + unexpected_png_path = sequence_dir / "0.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" + expected_png_path = sequence_dir / "000.png" assert expected_png_path.exists(), "png file not found at " + str(expected_png_path) From c6a411a59828f9661fd93b0246156ae9106c857f Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Fri, 28 Aug 2026 19:13:06 +0200 Subject: [PATCH 10/14] Preserve dry-run session intent --- docs/source/guides/deep_dive.rst | 11 +++++--- docs/source/tutorials/output_and_config.rst | 14 +++++++--- manim/_config/render_session.py | 14 +++++++--- manim/manager.py | 2 +- tests/module/test_manager.py | 14 ++++++++++ tests/test_config.py | 31 ++++++++++++++++++--- 6 files changed, 69 insertions(+), 17 deletions(-) diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index d980a1f141..6f70a7ab9c 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -278,10 +278,13 @@ continues as follows: The session specification separates primary artifact intent (an ``OutputSpec``) from presentation requests such as opening the completed artifact or displaying -a live preview. Resolution also validates requests against the selected -renderer's capabilities. For example, Cairo rejects live preview, while OpenGL -advertises support for it. With ``format = auto``, requesting live preview -selects no file output; a concrete format records the render as well. +a live preview. It also preserves whether dry-run execution was requested. A dry +run and an artifact-less render both have an effective output format of ``none``, +but the reason remains available without rereading mutable global configuration. +Resolution also validates requests against the selected renderer's capabilities. +For example, Cairo rejects live preview, while OpenGL advertises support for it. +With ``format = auto``, requesting live preview selects no file output; a +concrete format records the render as well. Inspecting the initialization methods of both renderers shows that they instantiate a :class:`.SceneFileWriter`. The writer must receive the already diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index 46491faea8..012db9acd9 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -167,10 +167,16 @@ The ``--format`` option selects one primary artifact: * - ``png-sequence`` - Evaluates the full frame progression and writes every frame as PNG. * - ``none`` - - Evaluates the scene without producing a primary media artifact. - -``--dry_run`` also suppresses media output, but remains a separate execution -request rather than modifying the configured format. + - Evaluates the scene without producing a primary media artifact. This + controls artifact persistence only: live preview, for example, still + rasterizes and presents frames while the resolved format is ``none``. + +``--dry_run`` also suppresses media output and cannot be combined with live +preview, but remains a separate execution request rather than modifying the +configured format. The immutable render-session specification preserves this +reason explicitly, so execution coordination does not need to reread mutable +global configuration to distinguish a dry run from another artifact-less +session. Sections diff --git a/manim/_config/render_session.py b/manim/_config/render_session.py index 23ff7f623d..879a8b4baa 100644 --- a/manim/_config/render_session.py +++ b/manim/_config/render_session.py @@ -27,10 +27,11 @@ class PresentationSpec: @dataclass(frozen=True, slots=True) class RenderSessionSpec: - """Validated output and presentation intent for one render session.""" + """Validated artifact, presentation, and execution intent for one session.""" output: OutputSpec presentation: PresentationSpec + dry_run: bool class _SessionConfigSource(Protocol): @@ -52,8 +53,9 @@ def resolve_render_session( ) -> RenderSessionSpec: """Resolve and validate one renderer-independent session request.""" live_preview = config.live_preview or config.enable_gui + dry_run = config.dry_run requested_format = OutputFormat.parse(config.format) - if config.dry_run: + if dry_run: requested_format = OutputFormat.NONE save_sections = False else: @@ -82,7 +84,7 @@ def resolve_render_session( f"{renderer_name} does not support live preview. " "Select a renderer with live-preview support or remove --live-preview.", ) - if live_preview and config.dry_run: + if live_preview and dry_run: raise ValueError("--live-preview cannot be combined with --dry_run.") if live_preview and output.is_still: raise ValueError( @@ -96,4 +98,8 @@ def resolve_render_session( if presentation.show_in_file_browser and not output.enabled: raise ValueError("--show_in_file_browser requires a media artifact.") - return RenderSessionSpec(output=output, presentation=presentation) + return RenderSessionSpec( + output=output, + presentation=presentation, + dry_run=dry_run, + ) diff --git a/manim/manager.py b/manim/manager.py index e6444c1c43..9817f0ceef 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -94,7 +94,7 @@ def output_spec(self) -> OutputSpec: @property def session_spec(self) -> RenderSessionSpec: - """Return the immutable output and presentation intent for this session.""" + """Return the immutable artifact, presentation, and execution intent.""" return self.scene.session_spec @property diff --git a/tests/module/test_manager.py b/tests/module/test_manager.py index ad1ccb7257..5998d5e2d0 100644 --- a/tests/module/test_manager.py +++ b/tests/module/test_manager.py @@ -34,10 +34,24 @@ def test_manager_exposes_the_session_output_snapshot(config): config.format = "none" config.preview = False + config.dry_run = True assert manager.output_spec.format is OutputFormat.GIF assert manager.output_spec is scene.renderer.file_writer.output_spec assert manager.session_spec.presentation.open_after_render is True + assert manager.session_spec.dry_run is False + + +def test_manager_exposes_dry_run_session_intent(config): + config.format = "gif" + config.dry_run = True + scene = Scene() + manager = Manager(scene) + + config.dry_run = False + + assert manager.output_spec.format is OutputFormat.NONE + assert manager.session_spec.dry_run is True def test_post_render_preview_requires_an_artifact(dry_run): diff --git a/tests/test_config.py b/tests/test_config.py index 55272c8c8f..2a17f038ea 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -19,12 +19,16 @@ from tests.assert_utils import assert_dir_exists, assert_dir_filled, assert_file_exists -def _resolve_output(config): +def _resolve_session(config): return resolve_render_session( config, RendererCapabilities(live_preview=True), renderer_name="TestRenderer", - ).output + ) + + +def _resolve_output(config): + return _resolve_session(config).output def test_tempconfig(config): @@ -98,11 +102,24 @@ def test_transparent_auto_output_resolves_to_mov(config): assert _resolve_output(config).format is OutputFormat.MOV +def test_explicit_no_output_is_not_dry_run(config): + config.format = "none" + + session = _resolve_session(config) + + assert session.output.format is OutputFormat.NONE + assert session.dry_run is False + + def test_live_preview_auto_output_resolves_to_none(config): config.format = "auto" config.live_preview = True - assert _resolve_output(config).format is OutputFormat.NONE + session = _resolve_session(config) + + assert session.output.format is OutputFormat.NONE + assert session.presentation.live_preview is True + assert session.dry_run is False def test_live_preview_requires_renderer_capability(config): @@ -138,10 +155,16 @@ def test_explicit_transparent_mp4_is_rejected(config): def test_dry_run_resolves_no_output_without_mutating_output_request(config): config.format = "gif" + config.save_sections = True config.dry_run = True - assert _resolve_output(config).format is OutputFormat.NONE + session = _resolve_session(config) + + assert session.output.format is OutputFormat.NONE + assert session.output.save_sections is False + assert session.dry_run is True assert config.format == "gif" + assert config.save_sections is True def test_save_last_frame_resolves_to_still_output(config): From 02cc9d1925f25fb2114a7b258b440176f98866b6 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 29 Aug 2026 19:35:15 +0200 Subject: [PATCH 11/14] Apply batched suggestions from code review Co-authored-by: nikolajmunk <28557236+nikolajmunk@users.noreply.github.com> --- docs/source/guides/deep_dive.rst | 21 ++++++++++----------- docs/source/tutorials/output_and_config.rst | 15 +++++++-------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index 6f70a7ab9c..4a47bfedfa 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -288,12 +288,11 @@ concrete format records the render as well. Inspecting the initialization methods of both renderers shows that they instantiate a :class:`.SceneFileWriter`. The writer must receive the already -resolved ``OutputSpec``; it does not reinterpret the global configuration to -decide whether or what to write. It remains Manim's interface to ``libav`` for +resolved ``OutputSpec``. It remains Manim's interface to ``libav`` for encoding media. The Cairo renderer (see the implementation `here `__) does not require further renderer-specific initialization. OpenGL creates a -window only when the resolved presentation specification requests live preview. +window only when the resolved presentation specification requests a live preview. The ``-p`` / ``--preview`` option does not create this window; it opens the completed artifact after rendering. @@ -338,14 +337,14 @@ The first three call the corresponding customizable scene hooks: After these hooks have run, :meth:`.Manager.post_construct` asks the renderer to finish the scene. For Cairo this calls :meth:`.CairoRenderer.scene_finished`, which checks the resolved output intent and tells the :class:`.SceneFileWriter` -to finish time-based output. For video output, the file writer waits for partial -movie files that are still being encoded and combines them into the final movie. -Final-state PNG output skips intermediate animation frames. A video request for -a scene without any play calls produces a useful still image instead of an empty -movie. The writer records the completed artifact as ``final_file_path`` without -replacing the configured ``output_file`` value. After finalization, the manager -uses the presentation specification to open the artifact or reveal it in the -file browser when requested. +to finish any time-based output. For video output, the file writer waits for +partial movie files that are still being encoded and combines them into the +final movie. Single-PNG output skips any remaining animation frames and renders +a single image. A video request for a scene without any play calls produces a +useful still image instead of an empty movie. The writer records the completed +artifact as ``final_file_path``. After finalization, the manager uses the +presentation specification to open the artifact or reveal it in the file +browser. **Back in our toy example,** the call to :meth:`.Scene.render` creates a manager, then :meth:`.Manager.render` triggers :meth:`.Scene.setup` (which only consists of diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index 012db9acd9..476103ffef 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -160,7 +160,7 @@ The ``--format`` option selects one primary artifact: concrete format is given. * - ``mp4``, ``mov``, ``webm`` or ``gif`` - Produces the selected video format. Transparent MP4 is rejected because - that container does not support the required alpha channel. + that container does not support alpha transparency. * - ``png`` - Fast-forwards animations and writes only the final scene state. This is equivalent to ``-s``. @@ -172,11 +172,9 @@ The ``--format`` option selects one primary artifact: rasterizes and presents frames while the resolved format is ``none``. ``--dry_run`` also suppresses media output and cannot be combined with live -preview, but remains a separate execution request rather than modifying the -configured format. The immutable render-session specification preserves this -reason explicitly, so execution coordination does not need to reread mutable -global configuration to distinguish a dry run from another artifact-less -session. +preview, but it counts as a separate execution request rather than modifying the +choice of output format. This allows Manim to function as if it were rendering a +normal scene, but without producing any artifact. Sections @@ -337,8 +335,9 @@ the file browser at the location of the animation instead, use The separate ``-l`` (or ``--live-preview``) option asks a capable renderer to display frames while rendering. The OpenGL renderer supports this mode. Live -preview with the default ``--format=auto`` does not write a media file; pass a -concrete format such as ``--format=mp4`` to display and record simultaneously. +preview with the default ``--format=auto`` does not produce a media file; you +must pass a concrete format such as ``--format=mp4`` to display and record +simultaneously. Finally, by default Manim outputs ``.mp4`` files. To request GIF output instead, use ``--format=gif``. GIF and final-frame PNG names include the installed Manim From 1f6e643a14824abcf16183f98e8b68b7cbb54022 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 29 Aug 2026 20:17:36 +0200 Subject: [PATCH 12/14] Address output documentation review feedback --- docs/source/contributing/testing.rst | 4 +-- docs/source/guides/configuration.rst | 6 ++-- docs/source/guides/deep_dive.rst | 40 +++++++++++---------- docs/source/tutorials/output_and_config.rst | 17 +++++---- manim/_config/output.py | 2 +- manim/_config/render_session.py | 2 +- manim/_config/utils.py | 4 +-- 7 files changed, 38 insertions(+), 37 deletions(-) diff --git a/docs/source/contributing/testing.rst b/docs/source/contributing/testing.rst index e8d58f5496..8013fac254 100644 --- a/docs/source/contributing/testing.rst +++ b/docs/source/contributing/testing.rst @@ -292,8 +292,8 @@ Note the fixtures here: You have to generate a ``.json`` file first to be able to test your video. To do that, use ``helpers.save_control_data_from_video``. -For instance, a test that checks low-quality rendering first requires rendering -a video from a scene with the ``-ql`` flag. The example below tests +For instance, testing low-quality output requires first rendering a video from a +scene with the ``-ql`` flag. The example below tests ``SquareToCircle``, which lives in ``test_scene_rendering/simple_scene.py``. Change directories to ``tests/``, create a file (e.g. ``create\_data.py``) that you will remove as soon as diff --git a/docs/source/guides/configuration.rst b/docs/source/guides/configuration.rst index dafd1a3e75..ead4b3216f 100644 --- a/docs/source/guides/configuration.rst +++ b/docs/source/guides/configuration.rst @@ -62,9 +62,9 @@ instead of the whole video, you can execute manim -sqh SceneName -The equivalent ``--format=png`` spelling uses the same fast final-state-only -evaluation. Use ``--format=png-sequence`` when every rendered frame should be -written as a numbered PNG instead. +The equivalent ``--format=png`` spelling uses the same fast mode that saves only +the last frame. Use ``--format=png-sequence`` when every rendered frame should +be written as a numbered PNG instead. The following example specifies the output file name (with the :code:`-o` flag), renders only the first ten animations (:code:`-n` flag) with a white diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index 4a47bfedfa..271ceaf468 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -278,13 +278,19 @@ continues as follows: The session specification separates primary artifact intent (an ``OutputSpec``) from presentation requests such as opening the completed artifact or displaying -a live preview. It also preserves whether dry-run execution was requested. A dry -run and an artifact-less render both have an effective output format of ``none``, -but the reason remains available without rereading mutable global configuration. -Resolution also validates requests against the selected renderer's capabilities. -For example, Cairo rejects live preview, while OpenGL advertises support for it. -With ``format = auto``, requesting live preview selects no file output; a -concrete format records the render as well. +a live preview. It also records dry-run execution separately from artifact +selection. A dry run requests semantic scene evaluation without rasterizing +frames or using media and cache resources. In contrast, ``format = none`` only +suppresses the primary artifact; an OpenGL live preview with automatic output +still rasterizes and displays frames without writing a file. Both requests have +an effective output format of ``none``, so the session's ``dry_run`` field +preserves the intended execution behavior. + +The current renderer loops do not yet enforce no-raster dry runs; the session +field preserves that request for Manager-owned execution. Resolution also +validates requests against the selected renderer's capabilities. For example, +Cairo rejects live preview, while OpenGL advertises support for it. A concrete +format records the live preview as well. Inspecting the initialization methods of both renderers shows that they instantiate a :class:`.SceneFileWriter`. The writer must receive the already @@ -303,12 +309,9 @@ attribute is initially ``None`` unless the caller attaches a manager explicitly. .. warning:: - :class:`.Manager` is an incremental coordination boundary. At this stage the - scene captures the immutable session specification before renderer - initialization, while the renderer still owns its camera, clock, play count, - skip state, and file writer. The manager exposes the session and forwarding - views of renderer state. The scene and renderer therefore still have - substantial interplay that later refactors aim to remove. + The manager coordinates the scene lifecycle, while the renderer still owns + its camera, clock, play count, skip state, and file writer. The manager + currently exposes these through forwarding properties. The rest of this article is concerned with the last line in our toy example script:: @@ -339,12 +342,11 @@ finish the scene. For Cairo this calls :meth:`.CairoRenderer.scene_finished`, which checks the resolved output intent and tells the :class:`.SceneFileWriter` to finish any time-based output. For video output, the file writer waits for partial movie files that are still being encoded and combines them into the -final movie. Single-PNG output skips any remaining animation frames and renders -a single image. A video request for a scene without any play calls produces a -useful still image instead of an empty movie. The writer records the completed -artifact as ``final_file_path``. After finalization, the manager uses the -presentation specification to open the artifact or reveal it in the file -browser. +final movie. Last-frame PNG output fast-forwards animations and renders a single +image. A video request for a scene without any play calls produces a useful still +image instead of an empty movie. The writer records the completed artifact as +``final_file_path``. After finalization, the manager uses the presentation +specification to open the artifact or reveal it in the file browser. **Back in our toy example,** the call to :meth:`.Scene.render` creates a manager, then :meth:`.Manager.render` triggers :meth:`.Scene.setup` (which only consists of diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index 476103ffef..4c5310bfb6 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -137,10 +137,14 @@ The corresponding folder structure looks like this: ├─texts └─Tex +``X.Y.Z`` in the PNG filename stands for the installed Manim version. Manim +adds this version suffix to GIF names and PNG files produced with ``-s`` when no +explicit ``--output_file`` is supplied. + Saving the last frame with ``-s`` can be combined with the flags for different resolutions, e.g. ``-s -ql``, ``-s -qh``. The equivalent -``--format=png`` spelling also selects this fast final-state-only mode. To write -every rendered frame as a numbered PNG instead, use +``--format=png`` spelling uses the same fast mode that saves only the last frame. +To write every rendered frame as a numbered PNG instead, use ``--format=png-sequence``. The sequence is stored in a scene-specific directory, for example ``media/images/scene/SquareToCircle/0000.png``. @@ -162,8 +166,8 @@ The ``--format`` option selects one primary artifact: - Produces the selected video format. Transparent MP4 is rejected because that container does not support alpha transparency. * - ``png`` - - Fast-forwards animations and writes only the final scene state. This is - equivalent to ``-s``. + - Fast-forwards animations and writes only the last frame of the scene. + This is equivalent to ``-s``. * - ``png-sequence`` - Evaluates the full frame progression and writes every frame as PNG. * - ``none`` @@ -339,11 +343,6 @@ preview with the default ``--format=auto`` does not produce a media file; you must pass a concrete format such as ``--format=mp4`` to display and record simultaneously. -Finally, by default Manim outputs ``.mp4`` files. To request GIF output instead, -use ``--format=gif``. GIF and final-frame PNG names include the installed Manim -version when no explicit ``--output_file`` is supplied; ``X.Y.Z`` in the example -above stands for that version. - This was a quick review of some of the most frequent command-line flags. For a thorough review of all flags available, see the :doc:`thematic guide on Manim's configuration system `. diff --git a/manim/_config/output.py b/manim/_config/output.py index 0eb5bc3d3f..9b6d399fd7 100644 --- a/manim/_config/output.py +++ b/manim/_config/output.py @@ -75,7 +75,7 @@ def is_video(self) -> bool: @property def is_still(self) -> bool: - """Whether only the evaluated final scene state is written as PNG.""" + """Whether only the last frame of the scene is written as PNG.""" return self.format is OutputFormat.PNG @property diff --git a/manim/_config/render_session.py b/manim/_config/render_session.py index 879a8b4baa..f6c1f28fa4 100644 --- a/manim/_config/render_session.py +++ b/manim/_config/render_session.py @@ -88,7 +88,7 @@ def resolve_render_session( raise ValueError("--live-preview cannot be combined with --dry_run.") if live_preview and output.is_still: raise ValueError( - "Live preview cannot be combined with final-state-only PNG output.", + "Live preview cannot be combined with last-frame PNG output.", ) if presentation.open_after_render and not output.enabled: raise ValueError( diff --git a/manim/_config/utils.py b/manim/_config/utils.py index 4eb89964bd..613ed6d93b 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -949,7 +949,7 @@ def notify_outdated_version(self, value: bool) -> None: @property def save_last_frame(self) -> bool: - """Whether to use final-state-only PNG output (-s).""" + """Whether to save the last frame of the scene as a PNG (-s).""" return OutputFormat.parse(self.format) is OutputFormat.PNG @save_last_frame.setter @@ -1023,7 +1023,7 @@ def verbosity(self, val: str) -> None: def format(self) -> str | None: """Primary output format. - ``png`` writes only the evaluated final scene state; + ``png`` writes only the last frame of the scene; ``png-sequence`` writes every rendered frame. ``auto`` selects MP4 for opaque output and MOV for transparent output, while ``none`` disables media output. From fd21061d9feccdcae011158ed81da0f764fdb2a9 Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 29 Aug 2026 22:02:11 +0200 Subject: [PATCH 13/14] Respect explicit video output for static scenes --- docs/source/guides/deep_dive.rst | 19 ++++---- docs/source/tutorials/output_and_config.rst | 16 ++++--- manim/_config/output.py | 11 +++-- manim/_config/render_session.py | 3 ++ manim/cli/render/render_options.py | 4 +- manim/manager.py | 27 ++++++++++-- manim/renderer/cairo_renderer.py | 6 +-- manim/renderer/opengl_renderer.py | 12 +++--- tests/module/test_manager.py | 43 ++++++++++++++++++- tests/module/utils/test_file_ops.py | 20 +++++++++ tests/test_config.py | 27 +++++++++++- .../opengl/test_cli_flags_opengl.py | 16 +++---- .../test_cairo_renderer.py | 20 +++++++++ .../test_scene_rendering/test_file_writer.py | 7 ++- .../test_parallel_encoding.py | 2 + 15 files changed, 186 insertions(+), 47 deletions(-) diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index 271ceaf468..ed207aa80b 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -337,14 +337,17 @@ The first three call the corresponding customizable scene hooks: hook is more relevant for situations where Manim is used within other Python scripts). -After these hooks have run, :meth:`.Manager.post_construct` asks the renderer to -finish the scene. For Cairo this calls :meth:`.CairoRenderer.scene_finished`, -which checks the resolved output intent and tells the :class:`.SceneFileWriter` -to finish any time-based output. For video output, the file writer waits for -partial movie files that are still being encoded and combines them into the -final movie. Last-frame PNG output fast-forwards animations and renders a single -image. A video request for a scene without any play calls produces a useful still -image instead of an empty movie. The writer records the completed artifact as +After these hooks have run, :meth:`.Manager.post_construct` checks whether the +resolved output can be finalized. An explicit video format for a scene without +play calls raises an error instead of silently changing the requested artifact. +Otherwise, the manager asks the renderer to finish the scene. For Cairo this +calls :meth:`.CairoRenderer.scene_finished`, which tells the +:class:`.SceneFileWriter` to finish any time-based output. For video output, the +file writer waits for partial movie files that are still being encoded and +combines them into the final movie. Last-frame PNG output fast-forwards +animations and renders a single image. When automatic output selected a video +for a scene without play calls, the writer saves that image as a fallback PNG and +the manager logs a warning. The writer records the completed artifact as ``final_file_path``. After finalization, the manager uses the presentation specification to open the artifact or reveal it in the file browser. diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index 4c5310bfb6..5b6d36eddf 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -160,11 +160,13 @@ The ``--format`` option selects one primary artifact: - Behavior * - ``auto`` - The default. Produces MP4 for opaque output and MOV for transparent - output. When live preview is requested, it produces no file unless a - concrete format is given. + output. A scene without play or wait calls produces a last-frame PNG + instead and logs a warning. When live preview is requested, it produces + no file unless a concrete format is given. * - ``mp4``, ``mov``, ``webm`` or ``gif`` - - Produces the selected video format. Transparent MP4 is rejected because - that container does not support alpha transparency. + - Produces the selected video format. A scene without play or wait calls + cannot produce an explicitly requested video. Transparent MP4 is rejected + because that container does not support alpha transparency. * - ``png`` - Fast-forwards animations and writes only the last frame of the scene. This is equivalent to ``-s``. @@ -333,9 +335,9 @@ prototyping and testing. The other options that specify render quality are (1920x1080 60FPS), 2k (2560x1440 60FPS) and 4k quality (3840x2160 60FPS), respectively. -The ``-p`` flag plays the animation once it is rendered. If you want to open -the file browser at the location of the animation instead, use -``--show_in_file_browser``. You can also omit both options. +The ``-p`` flag opens the animation once it is rendered. +``--show_in_file_browser`` reveals the artifact in the file browser. The options +can be combined; Manim reveals the artifact first and then opens it for preview. The separate ``-l`` (or ``--live-preview``) option asks a capable renderer to display frames while rendering. The OpenGL renderer supports this mode. Live diff --git a/manim/_config/output.py b/manim/_config/output.py index 9b6d399fd7..059741b4e7 100644 --- a/manim/_config/output.py +++ b/manim/_config/output.py @@ -43,18 +43,23 @@ class OutputSpec: """Immutable, validated output intent for one render session. ``format`` is concrete: ``AUTO`` is resolved before this object is created. - The extension of cached video segments is deliberately separate from the - extension of the final artifact; GIF output, for example, uses encoded video - segments before final GIF assembly. + ``fallback_to_still`` allows an automatically selected video format to + produce a last-frame PNG when a scene has no play calls. The extension of + cached video segments is deliberately separate from the extension of the + final artifact; GIF output, for example, uses encoded video segments before + final GIF assembly. """ format: OutputFormat transparent: bool save_sections: bool + fallback_to_still: bool def __post_init__(self) -> None: if self.format is OutputFormat.AUTO: raise ValueError("OutputSpec requires a concrete output format.") + if self.fallback_to_still and not self.is_video: + raise ValueError("Still-image fallback requires a video output format.") if self.transparent and self.format is OutputFormat.MP4: raise ValueError( "MP4 output does not support an alpha channel. Use --format=mov " diff --git a/manim/_config/render_session.py b/manim/_config/render_session.py index f6c1f28fa4..480a1ad22e 100644 --- a/manim/_config/render_session.py +++ b/manim/_config/render_session.py @@ -55,6 +55,7 @@ def resolve_render_session( live_preview = config.live_preview or config.enable_gui dry_run = config.dry_run requested_format = OutputFormat.parse(config.format) + fallback_to_still = False if dry_run: requested_format = OutputFormat.NONE save_sections = False @@ -67,11 +68,13 @@ def resolve_render_session( requested_format = ( OutputFormat.MOV if config.transparent else OutputFormat.MP4 ) + fallback_to_still = True output = OutputSpec( format=requested_format, transparent=config.transparent, save_sections=save_sections, + fallback_to_still=fallback_to_still, ) presentation = PresentationSpec( open_after_render=config.preview, diff --git a/manim/cli/render/render_options.py b/manim/cli/render/render_options.py index 31d6c2eba8..faca82987b 100644 --- a/manim/cli/render/render_options.py +++ b/manim/cli/render/render_options.py @@ -135,7 +135,7 @@ def validate_resolution( case_sensitive=False, ), default=None, - help="Primary output format. PNG renders only the final scene state; " + help="Primary output format. PNG renders only the last frame; " "png-sequence writes every rendered frame.", ), option( @@ -143,7 +143,7 @@ def validate_resolution( "--save_last_frame", default=None, is_flag=True, - help="Fast-forward animations and save the final scene state as PNG " + help="Fast-forward animations and save the last frame as PNG " "(equivalent to --format=png).", ), option( diff --git a/manim/manager.py b/manim/manager.py index 9817f0ceef..4a1acd4de3 100644 --- a/manim/manager.py +++ b/manim/manager.py @@ -195,15 +195,34 @@ def construct(self) -> None: def post_construct(self) -> None: """Finalize output after scene construction and tear-down. - This asks the renderer to finish the scene and logs the number of played - animations. It intentionally runs after :meth:`tear_down` to preserve the - established render lifecycle. + This validates empty video output, asks the renderer to finish the scene, + and logs the number of played animations. It intentionally runs after + :meth:`tear_down` to preserve the established render lifecycle. """ + output = self.output_spec + empty_video_output = self.num_plays == 0 and output.is_video + if empty_video_output and not output.fallback_to_still: + raise RuntimeError( + f"{self.scene} has no play calls, so the explicitly requested " + f"{output.format.value.upper()} output cannot be produced. " + "Use --format=png to save its last frame.", + ) + # We have to reset these settings in case of multiple renders. self.renderer.scene_finished(self.scene) + if ( + empty_video_output + and output.fallback_to_still + and getattr(self.file_writer, "final_file_path", None) is not None + ): + logger.warning( + f"{self.scene} has no play calls. Automatic video output has " + "been saved as a PNG instead.", + ) + # Show info only if animations are rendered or to get image. - if self.num_plays or self.output_spec.enabled: + if self.num_plays or output.enabled: logger.info( f"Rendered {str(self.scene)}\nPlayed {self.num_plays} animations", ) diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index c015b2383f..df54aa1de6 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -279,9 +279,9 @@ def scene_finished(self, scene: Scene) -> None: self.static_image = None self.update_frame(scene) - # A video request for a scene with no plays retains the established - # behavior of producing a useful still image instead of an empty movie. - if output.is_still or (not self.num_plays and output.is_video): + # Automatically selected video output falls back to a last-frame PNG + # when a scene has no play calls. + if output.is_still or (not self.num_plays and output.fallback_to_still): if self.num_plays: self.static_image = None self.update_frame(scene) diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index 7196b9e879..34c70718ee 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -970,18 +970,18 @@ def scene_finished(self, scene: Scene) -> None: def should_save_last_frame(self) -> bool: """ - Determine whether the last frame of the scene should be saved, - i.e. if one of the following conditions is met: - - The configuration option 'save_last_frame' is enabled. - - The scene is not in interactive mode. - - This is the first play (i.e., num_plays == 0). + Determine whether the last frame of the scene should be saved. + + This is true for explicit last-frame PNG output and for automatic video + output when the scene has no play calls. Interactive scenes do not use + the automatic fallback. """ output = self.file_writer.output_spec if output.is_still: return True if self.scene.interactive_mode: return False - return self.num_plays == 0 and output.is_video + return self.num_plays == 0 and output.fallback_to_still def get_image(self) -> Image.Image: """ diff --git a/tests/module/test_manager.py b/tests/module/test_manager.py index 5998d5e2d0..5423126979 100644 --- a/tests/module/test_manager.py +++ b/tests/module/test_manager.py @@ -3,7 +3,7 @@ import copy import datetime import threading -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest import srt @@ -12,6 +12,7 @@ from manim._config.output import OutputFormat from manim.animation.animation import Wait from manim.constants import RendererType +from manim.renderer.protocol import RendererCapabilities from manim.scene.scene import SceneInteractRerun from manim.utils.exceptions import EndSceneEarlyException, RerunSceneException @@ -61,6 +62,46 @@ def test_post_render_preview_requires_an_artifact(dry_run): Manager(scene).render(preview=True) +@pytest.mark.parametrize("output_format", ["mp4", "mov", "webm", "gif"]) +def test_manager_rejects_explicit_video_for_scene_without_play_calls( + config, + output_format, +): + config.format = output_format + renderer = Mock() + renderer.capabilities = RendererCapabilities() + renderer.num_plays = 0 + scene = Scene(renderer) + manager = Manager(scene) + + with pytest.raises( + RuntimeError, + match=f"explicitly requested {output_format.upper()}", + ): + manager.post_construct() + + renderer.scene_finished.assert_not_called() + + +def test_manager_warns_when_automatic_video_falls_back_to_still(config): + config.format = "auto" + renderer = Mock() + renderer.capabilities = RendererCapabilities() + renderer.num_plays = 0 + scene = Scene(renderer) + renderer.file_writer.final_file_path = "scene.png" + manager = Manager(scene) + + with patch("manim.manager.logger.warning") as warning: + manager.post_construct() + + renderer.scene_finished.assert_called_once_with(scene) + warning.assert_called_once_with( + f"{scene} has no play calls. Automatic video output has been saved as a " + "PNG instead.", + ) + + def test_manager_rejects_second_attachment(dry_run): scene = Scene() manager = Manager(scene) diff --git a/tests/module/utils/test_file_ops.py b/tests/module/utils/test_file_ops.py index b56684eb0c..3cca3272f2 100644 --- a/tests/module/utils/test_file_ops.py +++ b/tests/module/utils/test_file_ops.py @@ -1,8 +1,10 @@ from __future__ import annotations from pathlib import Path +from unittest.mock import Mock, call from manim import * +from manim.utils import file_ops from tests.assert_utils import assert_dir_exists, assert_file_not_exists from tests.utils.video_tester import * @@ -29,3 +31,21 @@ def test_guarantee_empty_existence(tmp_path: Path): assert_dir_exists(test_dir) # test if dir got cleaned assert_file_not_exists(test_dir / "test.txt") + + +def test_open_media_file_can_reveal_and_preview(monkeypatch, tmp_path: Path): + artifact = tmp_path / "scene.mp4" + file_writer = Mock(final_file_path=artifact) + open_file = Mock() + monkeypatch.setattr(file_ops, "open_file", open_file) + + file_ops.open_media_file( + file_writer, + preview=True, + show_in_file_browser=True, + ) + + assert open_file.call_args_list == [ + call(artifact, True), + call(artifact, False), + ] diff --git a/tests/test_config.py b/tests/test_config.py index 2a17f038ea..4d078fd106 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,7 +8,7 @@ from click.testing import CliRunner from manim import RIGHT, WHITE, Scene, Square, Tex, Text, Vector, tempconfig -from manim._config.output import OutputFormat +from manim._config.output import OutputFormat, OutputSpec from manim._config.render_session import resolve_render_session from manim._config.utils import ManimConfig from manim.cli.render.commands import render @@ -99,7 +99,28 @@ def test_transparent_auto_output_resolves_to_mov(config): config.format = "auto" config.transparent = True - assert _resolve_output(config).format is OutputFormat.MOV + output = _resolve_output(config) + + assert output.format is OutputFormat.MOV + assert output.fallback_to_still is True + + +def test_only_automatic_video_output_allows_still_fallback(config): + config.format = "auto" + assert _resolve_output(config).fallback_to_still is True + + config.format = "mp4" + assert _resolve_output(config).fallback_to_still is False + + +def test_still_fallback_requires_video_output(): + with pytest.raises(ValueError, match="requires a video output format"): + OutputSpec( + OutputFormat.PNG, + transparent=False, + save_sections=False, + fallback_to_still=True, + ) def test_explicit_no_output_is_not_dry_run(config): @@ -118,6 +139,7 @@ def test_live_preview_auto_output_resolves_to_none(config): session = _resolve_session(config) assert session.output.format is OutputFormat.NONE + assert session.output.fallback_to_still is False assert session.presentation.live_preview is True assert session.dry_run is False @@ -162,6 +184,7 @@ def test_dry_run_resolves_no_output_without_mutating_output_request(config): assert session.output.format is OutputFormat.NONE assert session.output.save_sections is False + assert session.output.fallback_to_still is False assert session.dry_run is True assert config.format == "gif" assert config.save_sections is True diff --git a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py index de832bf683..bebaa93d19 100644 --- a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py +++ b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py @@ -245,9 +245,7 @@ def test_default_video_output_with_non_static_scene( @pytest.mark.slow -def test_image_output_for_static_scene_with_video_format( - tmp_path, manim_cfg_file, simple_scenes_path -): +def test_explicit_video_output_for_static_scene_fails(tmp_path, simple_scenes_path): scene_name = "StaticScene" command = [ sys.executable, @@ -262,14 +260,12 @@ def test_image_output_for_static_scene_with_video_format( str(simple_scenes_path), scene_name, ] - out, err, exit_code = capture(command) - assert exit_code == 0, err - - is_empty = not any((tmp_path / "videos").iterdir()) - assert not is_empty, "running manim with static scene rendered a video" + _, err, exit_code = capture(command) - is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) - assert not is_empty, "running manim without animations did not render an image" + assert exit_code == 1 + assert "explicitly requested MP4" in err + assert not list(tmp_path.rglob("*.mp4")) + assert not list(tmp_path.rglob("*.png")) @pytest.mark.slow diff --git a/tests/test_scene_rendering/test_cairo_renderer.py b/tests/test_scene_rendering/test_cairo_renderer.py index 58af612b88..4a5229e966 100644 --- a/tests/test_scene_rendering/test_cairo_renderer.py +++ b/tests/test_scene_rendering/test_cairo_renderer.py @@ -22,6 +22,26 @@ def test_render(using_temp_config, disabling_caching): assert config.output_file == "" +def test_automatic_output_uses_still_for_static_scene(using_temp_config): + scene = StaticScene() + + scene.render() + + assert scene.renderer.file_writer.output_spec.fallback_to_still is True + assert scene.renderer.file_writer.final_file_path.suffix == ".png" + assert_file_exists(scene.renderer.file_writer.final_file_path) + + +def test_explicit_video_output_for_static_scene_fails(using_temp_config): + config.format = "mp4" + scene = StaticScene() + + with pytest.raises(RuntimeError, match="explicitly requested MP4"): + scene.render() + + assert not hasattr(scene.renderer.file_writer, "final_file_path") + + def test_skipping_status_with_from_to_and_up_to(using_temp_config, disabling_caching): """Test if skip_animations is well updated when -n flag is passed""" config.from_animation_number = 2 diff --git a/tests/test_scene_rendering/test_file_writer.py b/tests/test_scene_rendering/test_file_writer.py index 4c94509b1f..66d49feb0b 100644 --- a/tests/test_scene_rendering/test_file_writer.py +++ b/tests/test_scene_rendering/test_file_writer.py @@ -195,7 +195,12 @@ def _new_file_writer(scene_name: str) -> SceneFileWriter: return SceneFileWriter( renderer, scene_name, - OutputSpec(OutputFormat.MP4, transparent=False, save_sections=False), + OutputSpec( + OutputFormat.MP4, + transparent=False, + save_sections=False, + fallback_to_still=False, + ), ) diff --git a/tests/test_scene_rendering/test_parallel_encoding.py b/tests/test_scene_rendering/test_parallel_encoding.py index fa58a7671b..43183fc078 100644 --- a/tests/test_scene_rendering/test_parallel_encoding.py +++ b/tests/test_scene_rendering/test_parallel_encoding.py @@ -24,11 +24,13 @@ OutputFormat.MP4, transparent=False, save_sections=False, + fallback_to_still=False, ) _NO_OUTPUT = OutputSpec( OutputFormat.NONE, transparent=False, save_sections=False, + fallback_to_still=False, ) _UNIQUE_PLAYS = 6 _TOTAL_PLAYS = _UNIQUE_PLAYS + 2 From c5ca93f0e4fbac0e9ba0607fd11cac6b5812a38f Mon Sep 17 00:00:00 2001 From: Benjamin Hackl Date: Sat, 29 Aug 2026 22:48:12 +0200 Subject: [PATCH 14/14] Fix automatic output write-all test --- tests/test_scene_rendering/opengl/test_cli_flags_opengl.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py index bebaa93d19..c4bb36293a 100644 --- a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py +++ b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py @@ -332,7 +332,6 @@ def test_a_flag(tmp_path, manim_cfg_file, infallible_scenes_path): "manim", "--renderer", "opengl", - "--format=mp4", "-ql", "--media_dir", str(tmp_path),