diff --git a/docs/source/guides/deep_dive.rst b/docs/source/guides/deep_dive.rst index ed207aa80b..d3891dc58e 100644 --- a/docs/source/guides/deep_dive.rst +++ b/docs/source/guides/deep_dive.rst @@ -274,6 +274,12 @@ continues as follows: self.renderer.capabilities, renderer_name=type(self.renderer).__name__, ) + self.output_plan = resolve_output_plan( + resolve_media_layout(...), + self.session_spec.output, + scene_name=type(self).__name__, + requested_output_name=..., + ) self.renderer.init_scene(self, self.session_spec) The session specification separates primary artifact intent (an ``OutputSpec``) @@ -292,10 +298,18 @@ 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. +The scene then resolves existing directory templates once into an immutable +output plan containing exact scene-specific artifact, section, image-sequence, +and cache paths. Planning performs no file I/O and creates no directories. The +resolved format determines the artifact suffix; ``output_file`` supplies only a +name and cannot change the format. + Inspecting the initialization methods of both renderers shows that they instantiate a :class:`.SceneFileWriter`. The writer must receive the already -resolved ``OutputSpec``. It remains Manim's interface to ``libav`` for -encoding media. The Cairo renderer (see the implementation `here +resolved ``OutputSpec`` and output plan; it does not reinterpret global +configuration to decide what or where to write. Directories are created lazily +when their owning operation first writes. The writer 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 a live preview. @@ -309,9 +323,10 @@ attribute is initially ``None`` unless the caller attaches a manager explicitly. .. warning:: - 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 scene captures the immutable session specification and output plan before + renderer initialization. The manager coordinates the scene lifecycle, while + the renderer still owns its camera, clock, play count, skip state, and file + writer. The manager exposes these through forwarding properties. The rest of this article is concerned with the last line in our toy example script:: diff --git a/docs/source/tutorials/output_and_config.rst b/docs/source/tutorials/output_and_config.rst index 5b6d36eddf..c04c91173c 100644 --- a/docs/source/tutorials/output_and_config.rst +++ b/docs/source/tutorials/output_and_config.rst @@ -148,6 +148,23 @@ 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``. +Customizing output directories +****************************** + +The canonical layout can be customized through the ordinary directory options in +the ``[CLI]`` section of ``manim.cfg``. These options support placeholders and may +refer to one another; for example: + +.. code-block:: ini + + [CLI] + media_dir = project-media + video_dir = {media_dir}/renders/{module_name}/{quality} + images_dir = {media_dir}/stills/{module_name} + sections_dir = {video_dir}/sections + partial_movie_dir = {video_dir}/partial_movie_files/{scene_name} + log_dir = {media_dir}/logs + Output formats ************** @@ -182,6 +199,13 @@ 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. +``-o`` / ``--output_file`` names the primary artifact for a single selected scene; +it does not select the format. Manim appends the resolved format suffix unless the +name already ends with it. For example, ``-o movie.mp4 --format=mp4`` produces +``movie.mp4``, while ``-o movie.mov --format=mp4`` produces ``movie.mov.mp4``. A +single output name is ambiguous for a multi-scene render, so ``-o`` cannot be +combined with ``--write_all`` or with several selected scene names. + Sections ******** @@ -252,13 +276,15 @@ If you do this, the ``media`` folder will look like this: │ ├── 3163782288_524160878_1793580042.mp4 │ └── partial_movie_file_list.txt └── sections - ├── ElaborateSceneWithSections_0000.mp4 - ├── ElaborateSceneWithSections_0001.mp4 - ├── ElaborateSceneWithSections_0002.mp4 + ├── ElaborateSceneWithSections_0000_create-square.mp4 + ├── ElaborateSceneWithSections_0001_transform-to-circle.mp4 + ├── ElaborateSceneWithSections_0003_fade-out.mp4 └── ElaborateSceneWithSections.json As you can see each section receives their own output video in the ``sections`` directory. -The JSON file in here contains some useful information for each section: +Section names are normalized into safe filename components, while the original names +are retained in the JSON index. The JSON file contains some useful information for +each section: .. code-block:: json @@ -266,7 +292,7 @@ The JSON file in here contains some useful information for each section: { "name": "create square", "type": "default.normal", - "video": "ElaborateSceneWithSections_0000.mp4", + "video": "ElaborateSceneWithSections_0000_create-square.mp4", "codec_name": "h264", "width": 854, "height": 480, @@ -277,7 +303,7 @@ The JSON file in here contains some useful information for each section: { "name": "transform to circle", "type": "default.normal", - "video": "ElaborateSceneWithSections_0001.mp4", + "video": "ElaborateSceneWithSections_0001_transform-to-circle.mp4", "codec_name": "h264", "width": 854, "height": 480, @@ -288,7 +314,7 @@ The JSON file in here contains some useful information for each section: { "name": "fade out", "type": "default.normal", - "video": "ElaborateSceneWithSections_0002.mp4", + "video": "ElaborateSceneWithSections_0003_fade-out.mp4", "codec_name": "h264", "width": 854, "height": 480, diff --git a/manim/_config/default.cfg b/manim/_config/default.cfg index 6c6cd9f402..757939ab9a 100644 --- a/manim/_config/default.cfg +++ b/manim/_config/default.cfg @@ -178,19 +178,6 @@ col1 = col2 = epilog = -# Overrides the default output folders, NOT the output file names. Note that -# if the custom_folders flag is present, the Tex and text files will not be put -# under media_dir, as is the default. -[custom_folders] -media_dir = videos -video_dir = {media_dir} -sections_dir = {media_dir} -images_dir = {media_dir} -text_dir = {media_dir}/temp_files -tex_dir = {media_dir}/temp_files -log_dir = {media_dir}/temp_files -partial_movie_dir = {media_dir}/partial_movie_files/{scene_name} - # Rich settings [logger] logging_keyword = bold yellow diff --git a/manim/_config/logger_utils.py b/manim/_config/logger_utils.py index 427cf41ca0..cb2a57049f 100644 --- a/manim/_config/logger_utils.py +++ b/manim/_config/logger_utils.py @@ -148,27 +148,14 @@ def parse_theme(parser: configparser.SectionProxy) -> Theme | None: return custom_theme -def set_file_logger(scene_name: str, module_name: str, log_dir: Path) -> None: - """Add a file handler to manim logger. - - The path to the file is built using ``config.log_dir``. +def set_file_logger(log_file_path: Path) -> None: + """Add a file handler for one exact, already resolved log path. Parameters ---------- - scene_name - The name of the scene, used in the name of the log file. - module_name - The name of the module, used in the name of the log file. - log_dir - Path to the folder where log files are stored. + log_file_path + Exact path of the log file for this scene. """ - # Note: The log file name will be - # _.log, gotten from config. So it - # can differ from the real name of the scene. would only - # appear if scene name was provided when manim was called. - log_file_name = f"{module_name}_{scene_name}.log" - log_file_path = log_dir / log_file_name - file_handler = logging.FileHandler(log_file_path, mode="w") file_handler.setFormatter(JSONFormatter()) diff --git a/manim/_config/output_plan.py b/manim/_config/output_plan.py new file mode 100644 index 0000000000..206eada0e2 --- /dev/null +++ b/manim/_config/output_plan.py @@ -0,0 +1,346 @@ +"""Internal scene-output path planning.""" + +from __future__ import annotations + +import os +import re +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +from manim import __version__ + +from .output import OutputFormat, OutputSpec + + +class _LayoutConfigSource(Protocol): + input_file: str | Path + output_file: str | Path + log_to_file: bool + zero_pad: int + + def get_dir(self, key: str, **kwargs: str) -> Path | None: ... + + +@dataclass(frozen=True, slots=True) +class MediaLayoutSpec: + """Exact output directories captured for one scene.""" + + video_dir: Path | None + images_dir: Path | None + sections_dir: Path | None + partial_movie_dir: Path | None + log_dir: Path | None + zero_pad: int + + def __post_init__(self) -> None: + for path in ( + self.video_dir, + self.images_dir, + self.sections_dir, + self.partial_movie_dir, + self.log_dir, + ): + if path is not None and not path.is_absolute(): + raise ValueError("Media layout paths must be absolute.") + if not 0 <= self.zero_pad <= 9: + raise ValueError("PNG zero padding must be between 0 and 9.") + + +@dataclass(frozen=True, slots=True) +class OutputPlan: + """Exact paths and dynamic child-name policy for one scene output.""" + + primary_artifact: Path | None + fallback_image: Path | None + image_sequence_dir: Path | None + segment_cache_dir: Path | None + sections_dir: Path | None + section_index: Path | None + subcaption_file: Path | None + concat_manifest: Path | None + output_stem: str + segment_extension: str | None + zero_pad: int + + def image_frame_path(self, frame_index: int) -> Path: + """Return the exact path for one PNG-sequence frame.""" + if self.image_sequence_dir is None: + raise ValueError("This output plan does not contain an image sequence.") + if frame_index < 0: + raise ValueError("Frame indices must be non-negative.") + return self.image_sequence_dir / f"{frame_index:0{self.zero_pad}d}.png" + + def segment_path(self, cache_key: str) -> Path: + """Return the exact path for one silent cached video segment.""" + if self.segment_cache_dir is None or self.segment_extension is None: + raise ValueError("This output plan does not contain video segments.") + if not cache_key or Path(cache_key).name != cache_key: + raise ValueError("A cache key must be a non-empty filename component.") + return self.segment_cache_dir / f"{cache_key}{self.segment_extension}" + + def section_path(self, index: int, name: str) -> Path: + """Return the exact path for one derived section video.""" + if self.sections_dir is None or self.segment_extension is None: + raise ValueError("This output plan does not contain section output.") + if index < 0: + raise ValueError("Section indices must be non-negative.") + section_slug = _slugify_section_name(name) + return self.sections_dir / ( + f"{self.output_stem}_{index:04}_{section_slug}{self.segment_extension}" + ) + + +def _slugify_section_name(name: str) -> str: + """Return a safe filename component while preserving Unicode words.""" + if not isinstance(name, str): + raise TypeError("Section names must be strings.") + normalized = unicodedata.normalize("NFKC", name) + return re.sub(r"[^\w]+", "-", normalized).strip("-_") or "section" + + +def _absolute_lexical(path: Path, working_directory: Path) -> Path: + if not working_directory.is_absolute(): + raise ValueError("The output planning working directory must be absolute.") + anchored = path if path.is_absolute() else working_directory / path + return Path(os.path.normpath(anchored)) + + +def _required_dir( + config: _LayoutConfigSource, + key: str, + *, + working_directory: Path, + module_name: str, + scene_name: str, +) -> Path: + path = config.get_dir(key, module_name=module_name, scene_name=scene_name) + if path is None: + raise ValueError(f"{key} must not be empty for the requested output.") + return _absolute_lexical(path, working_directory) + + +def resolve_module_name(config: _LayoutConfigSource) -> str: + """Resolve the source module name used by configured directory templates.""" + if not config.input_file: + return "" + input_file = config.get_dir("input_file") + if input_file is None: + return "" + return input_file.stem + + +def resolve_requested_output_name( + config: _LayoutConfigSource, +) -> Path | None: + """Resolve the optional user-requested output name without choosing a format.""" + if not config.output_file: + return None + output_file = config.get_dir("output_file") + if output_file is None: + return None + return output_file + + +def resolve_media_layout( + config: _LayoutConfigSource, + output: OutputSpec, + *, + module_name: str, + scene_name: str, + working_directory: Path, +) -> MediaLayoutSpec: + """Capture exact directories needed by one concrete scene output.""" + images_dir = None + video_dir = None + sections_dir = None + partial_movie_dir = None + + if output.is_still or output.is_image_sequence or output.fallback_to_still: + images_dir = _required_dir( + config, + "images_dir", + working_directory=working_directory, + module_name=module_name, + scene_name=scene_name, + ) + if output.is_video: + video_dir = _required_dir( + config, + "video_dir", + working_directory=working_directory, + module_name=module_name, + scene_name=scene_name, + ) + partial_movie_dir = _required_dir( + config, + "partial_movie_dir", + working_directory=working_directory, + module_name=module_name, + scene_name=scene_name, + ) + if output.save_sections: + sections_dir = _required_dir( + config, + "sections_dir", + working_directory=working_directory, + module_name=module_name, + scene_name=scene_name, + ) + + log_dir = None + if config.log_to_file: + log_dir = _required_dir( + config, + "log_dir", + working_directory=working_directory, + module_name=module_name, + scene_name=scene_name, + ) + + return MediaLayoutSpec( + video_dir=video_dir, + images_dir=images_dir, + sections_dir=sections_dir, + partial_movie_dir=partial_movie_dir, + log_dir=log_dir, + zero_pad=config.zero_pad, + ) + + +def _add_artifact_extension(path: Path, extension: str) -> Path: + if path.suffix == extension: + return path + return path.with_suffix(path.suffix + extension) + + +def _versioned(path: Path) -> Path: + return path.with_name(f"{path.stem}_ManimCE_v{__version__}{path.suffix}") + + +def _output_path(root: Path, name: Path, extension: str) -> Path: + return root / _add_artifact_extension(name, extension) + + +def resolve_output_plan( + layout: MediaLayoutSpec, + output: OutputSpec, + *, + scene_name: str, + requested_output_name: Path | None, +) -> OutputPlan: + """Resolve all stable artifact and cache paths for one scene.""" + if not scene_name: + raise ValueError("A scene name is required for output planning.") + + output_name = requested_output_name or Path(scene_name) + if output_name.name in {"", ".", ".."}: + raise ValueError("The requested output name must contain a filename.") + output_stem = output_name.stem + + if output.format is OutputFormat.NONE: + return OutputPlan( + primary_artifact=None, + fallback_image=None, + image_sequence_dir=None, + segment_cache_dir=None, + sections_dir=None, + section_index=None, + subcaption_file=None, + concat_manifest=None, + output_stem=output_stem, + segment_extension=None, + zero_pad=layout.zero_pad, + ) + + default_name = requested_output_name is None + normalized_png = None + versioned_png = None + if output.is_still or output.is_image_sequence or output.fallback_to_still: + images_dir = layout.images_dir + if images_dir is None: + raise ValueError("Image output requires an images directory.") + normalized_png = _output_path(images_dir, output_name, ".png") + versioned_png = _versioned(normalized_png) if default_name else normalized_png + + if output.is_still: + assert versioned_png is not None + return OutputPlan( + primary_artifact=versioned_png, + fallback_image=None, + image_sequence_dir=None, + segment_cache_dir=None, + sections_dir=None, + section_index=None, + subcaption_file=None, + concat_manifest=None, + output_stem=output_stem, + segment_extension=None, + zero_pad=layout.zero_pad, + ) + + if output.is_image_sequence: + assert normalized_png is not None + sequence_dir = normalized_png.with_suffix("") + return OutputPlan( + primary_artifact=sequence_dir, + fallback_image=None, + image_sequence_dir=sequence_dir, + segment_cache_dir=None, + sections_dir=None, + section_index=None, + subcaption_file=None, + concat_manifest=None, + output_stem=output_stem, + segment_extension=None, + zero_pad=layout.zero_pad, + ) + + if not output.is_video: + raise ValueError(f"Unsupported output format: {output.format.value}") + if layout.video_dir is None or layout.partial_movie_dir is None: + raise ValueError("Video output requires video and segment-cache directories.") + + artifact_extension = output.artifact_extension + assert artifact_extension is not None + primary_artifact = _output_path( + layout.video_dir, + output_name, + artifact_extension, + ) + if output.is_gif and default_name: + primary_artifact = _versioned(primary_artifact) + + sections_dir = layout.sections_dir + if output.save_sections and sections_dir is None: + raise ValueError("Section output requires a sections directory.") + section_index = ( + sections_dir / f"{output_stem}.json" if sections_dir is not None else None + ) + + return OutputPlan( + primary_artifact=primary_artifact, + fallback_image=versioned_png if output.fallback_to_still else None, + image_sequence_dir=None, + segment_cache_dir=layout.partial_movie_dir, + sections_dir=sections_dir, + section_index=section_index, + subcaption_file=primary_artifact.with_suffix(".srt"), + concat_manifest=layout.partial_movie_dir / "partial_movie_file_list.txt", + output_stem=output_stem, + segment_extension=output.segment_extension, + zero_pad=layout.zero_pad, + ) + + +def resolve_file_log_path( + layout: MediaLayoutSpec, + *, + module_name: str, + scene_name: str, +) -> Path | None: + """Return the exact optional log-file path for one scene.""" + if layout.log_dir is None: + return None + return layout.log_dir / f"{module_name}_{scene_name}.log" diff --git a/manim/_config/utils.py b/manim/_config/utils.py index 613ed6d93b..2f9eeb8c8d 100644 --- a/manim/_config/utils.py +++ b/manim/_config/utils.py @@ -263,7 +263,6 @@ class MyScene(Scene): ... "assets_dir", "background_color", "background_opacity", - "custom_folders", "disable_caching", "disable_caching_warning", "dry_run", @@ -590,7 +589,6 @@ def digest_parser(self, parser: configparser.ConfigParser) -> Self: "disable_caching", "disable_caching_warning", "flush_cache", - "custom_folders", "enable_gui", "fullscreen", "use_projection_fill_shaders", @@ -824,23 +822,6 @@ def digest_args(self, args: argparse.Namespace) -> Self: if fps: self.frame_rate = float(fps) - # Handle --custom_folders - if args.custom_folders: - for opt in [ - "media_dir", - "video_dir", - "sections_dir", - "images_dir", - "text_dir", - "tex_dir", - "log_dir", - "partial_movie_dir", - ]: - self[opt] = self._parser["custom_folders"].get(opt, raw=True) - # --media_dir overrides the default.cfg file - if hasattr(args, "media_dir") and args.media_dir: - self.media_dir = args.media_dir - # Handle --tex_template if args.tex_template: self.tex_template = TexTemplate.from_file(args.tex_template) @@ -1708,15 +1689,6 @@ def partial_movie_dir(self) -> str: def partial_movie_dir(self, value: str | Path) -> None: self._set_dir("partial_movie_dir", value) - @property - def custom_folders(self) -> str: - """Whether to use custom folder output.""" - return self._d["custom_folders"] - - @custom_folders.setter - def custom_folders(self, value: str | Path) -> None: - self._set_dir("custom_folders", value) - @property def input_file(self) -> str | Path: """Input file name.""" diff --git a/manim/cli/render/commands.py b/manim/cli/render/commands.py index a4768b2224..063c13dcaa 100644 --- a/manim/cli/render/commands.py +++ b/manim/cli/render/commands.py @@ -58,6 +58,14 @@ def __repr__(self) -> str: return str(self.__dict__) +def _validate_scene_batch_output_name(scene_classes: list[type]) -> None: + if config.output_file and (config.write_all or len(scene_classes) != 1): + raise ValueError( + "--output_file can only be used when rendering exactly one scene. " + "Remove --write_all or select a single scene.", + ) + + @cloup.command( context_settings=None, no_args_is_help=True, @@ -82,14 +90,17 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: config.digest_args(click_args) file = Path(config.input_file) - if config.renderer == RendererType.OPENGL: - from manim.renderer.opengl_renderer import OpenGLRenderer + try: + scene_classes = scene_classes_from_file(file) + _validate_scene_batch_output_name(scene_classes) + + if config.renderer == RendererType.OPENGL: + from manim.renderer.opengl_renderer import OpenGLRenderer - try: renderer = OpenGLRenderer() keep_running = True while keep_running: - for SceneClass in scene_classes_from_file(file): + for SceneClass in scene_classes: with tempconfig({}): scene = SceneClass(renderer) # Attach explicitly, but preserve custom Scene.render overrides. @@ -98,26 +109,20 @@ def render(**kwargs: Any) -> ClickArgs | dict[str, Any]: if rerun or config["write_all"]: renderer.num_plays = 0 continue - else: - keep_running = False - break + keep_running = False + break if config["write_all"]: keep_running = False - - except Exception: - error_console.print_exception() - sys.exit(1) - else: - for SceneClass in scene_classes_from_file(file): - try: + else: + for SceneClass in scene_classes: with tempconfig({}): scene = SceneClass() # Attach explicitly, but preserve custom Scene.render overrides. Manager(scene) scene.render() - except Exception: - error_console.print_exception() - sys.exit(1) + except Exception: + error_console.print_exception() + sys.exit(1) if config.notify_outdated_version: manim_info_url = "https://pypi.org/pypi/manim/json" diff --git a/manim/cli/render/global_options.py b/manim/cli/render/global_options.py index a2ee106236..fec1792384 100644 --- a/manim/cli/render/global_options.py +++ b/manim/cli/render/global_options.py @@ -61,13 +61,6 @@ def validate_gui_location( help="Specify the configuration file to use for render settings.", default=None, ), - option( - "--custom_folders", - is_flag=True, - default=None, - help="Use the folders defined in the [custom_folders] section of the " - "config file to define the output folder structure.", - ), option( "--disable_caching", is_flag=True, diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index df54aa1de6..768b27b82e 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -62,6 +62,7 @@ def init_scene(self, scene: Scene, session_spec: RenderSessionSpec) -> None: self, scene.__class__.__name__, output_spec=session_spec.output, + output_plan=scene.output_plan, ) def play( diff --git a/manim/renderer/opengl_renderer.py b/manim/renderer/opengl_renderer.py index 34c70718ee..d4ee03bb17 100644 --- a/manim/renderer/opengl_renderer.py +++ b/manim/renderer/opengl_renderer.py @@ -541,6 +541,7 @@ def init_scene(self, scene: Scene, session_spec: RenderSessionSpec) -> None: self, scene.__class__.__name__, output_spec=session_spec.output, + output_plan=scene.output_plan, ) self.scene = scene diff --git a/manim/scene/scene.py b/manim/scene/scene.py index e20984400d..93f7e09e2f 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -43,6 +43,14 @@ from manim.mobject.opengl.opengl_mobject import OpenGLPoint from .. import config, logger +from .._config.logger_utils import set_file_logger +from .._config.output_plan import ( + resolve_file_log_path, + resolve_media_layout, + resolve_module_name, + resolve_output_plan, + resolve_requested_output_name, +) from .._config.render_session import resolve_render_session from ..animation.animation import Animation, Wait, prepare_animation from ..camera.camera import Camera @@ -219,6 +227,29 @@ def __init__( self.renderer.capabilities, renderer_name=type(self.renderer).__name__, ) + scene_name = type(self).__name__ + module_name = resolve_module_name(config) + media_layout = resolve_media_layout( + config, + self.session_spec.output, + module_name=module_name, + scene_name=scene_name, + working_directory=Path.cwd(), + ) + self.output_plan = resolve_output_plan( + media_layout, + self.session_spec.output, + scene_name=scene_name, + requested_output_name=resolve_requested_output_name(config), + ) + self._log_file_path = resolve_file_log_path( + media_layout, + module_name=module_name, + scene_name=scene_name, + ) + if self._log_file_path is not None: + self._log_file_path.parent.mkdir(parents=True, exist_ok=True) + set_file_logger(self._log_file_path) self.renderer.init_scene(self, self.session_spec) self.mobjects: list[Mobject] = [] diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index 318f003fb0..3a71e5d7d5 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -34,15 +34,10 @@ from manim import __version__ from .. import config, logger -from .._config.logger_utils import set_file_logger from .._config.output import OutputSpec +from .._config.output_plan import OutputPlan from ..constants import RendererType -from ..utils.file_ops import ( - add_extension_if_not_present, - add_version_before_extension, - guarantee_existence, - modify_atime, -) +from ..utils.file_ops import modify_atime from ..utils.sounds import get_full_sound_file_path from .section import DefaultSectionType, Section @@ -219,21 +214,20 @@ class SceneFileWriter: """ - force_output_as_scene_name = False - def __init__( self, renderer: CairoRenderer | OpenGLRenderer, scene_name: str, output_spec: OutputSpec, + output_plan: OutputPlan, **kwargs: Any, ) -> None: self.renderer = renderer self.output_spec = output_spec + self.output_plan = output_plan self._inflight_encode_jobs: list[_PartialMovieEncodeJob] = [] self._inflight_by_path: dict[str, _PartialMovieEncodeJob] = {} self._current_encode_job: _PartialMovieEncodeJob | None = None - self.init_output_directories(scene_name) self.init_audio() self.frame_count = 0 self.partial_movie_files: list[str | None] = [] @@ -245,86 +239,59 @@ def __init__( name="autocreated", type_=DefaultSectionType.NORMAL, skip_animations=False ) - def init_output_directories(self, scene_name: str) -> None: - """Initialise output directories. - - Notes - ----- - The directories are read from ``config``, for example - ``config['media_dir']``. If the target directories don't already - exist, they will be created. - - """ - if not self.output_spec.enabled: - return - - module_name = config.get_dir("input_file").stem if config["input_file"] else "" - - if SceneFileWriter.force_output_as_scene_name: - self.output_name = Path(scene_name) - elif config["output_file"] and not config["write_all"]: - self.output_name = config.get_dir("output_file") - else: - self.output_name = Path(scene_name) - - if config["media_dir"]: - image_dir = guarantee_existence( - config.get_dir( - "images_dir", module_name=module_name, scene_name=scene_name - ), - ) - 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 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, self.output_spec.segment_extension - ) + @property + def output_name(self) -> Path: + """Return the planned logical output stem as a compatibility view.""" + return Path(self.output_plan.output_stem) - # 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 self.output_spec.save_sections: - self.sections_output_dir = guarantee_existence( - config.get_dir( - "sections_dir", module_name=module_name, scene_name=scene_name - ) - ) + @property + def image_file_path(self) -> Path: + """Return the planned still or video-fallback image path.""" + if self.output_spec.is_image_sequence: + return self.image_sequence_directory.with_suffix(".png") + path = ( + self.output_plan.primary_artifact + if self.output_spec.is_still + else self.output_plan.fallback_image + ) + if path is None: + raise AttributeError("This output plan does not contain an image path.") + return path - if self.output_spec.is_gif: - self.gif_file_path = add_extension_if_not_present( - self.output_name, ".gif" - ) + @property + def image_sequence_directory(self) -> Path: + """Return the planned PNG-sequence directory.""" + path = self.output_plan.image_sequence_dir + if path is None: + raise AttributeError("This output plan does not contain an image sequence.") + return path - if not config["output_file"]: - self.gif_file_path = add_version_before_extension( - self.gif_file_path - ) + @property + def movie_file_path(self) -> Path: + """Return the planned primary video artifact path.""" + if not self.output_spec.is_video or self.output_plan.primary_artifact is None: + raise AttributeError("This output plan does not contain a video artifact.") + return self.output_plan.primary_artifact - self.gif_file_path = movie_dir / self.gif_file_path + @property + def gif_file_path(self) -> Path: + """Return the planned GIF artifact path.""" + if not self.output_spec.is_gif: + raise AttributeError("This output plan does not contain a GIF artifact.") + return self.movie_file_path - self.partial_movie_directory = guarantee_existence( - config.get_dir( - "partial_movie_dir", - scene_name=scene_name, - module_name=module_name, - ), - ) + @property + def sections_output_dir(self) -> Path: + """Return the planned sections directory, or the legacy empty path.""" + return self.output_plan.sections_dir or Path("") - if config["log_to_file"]: - log_dir = guarantee_existence(config.get_dir("log_dir")) - set_file_logger( - scene_name=scene_name, module_name=module_name, log_dir=log_dir - ) + @property + def partial_movie_directory(self) -> Path: + """Return the planned silent-segment cache directory.""" + path = self.output_plan.segment_cache_dir + if path is None: + raise AttributeError("This output plan does not contain video segments.") + return path def finish_last_section(self) -> None: """Delete current section if it is empty.""" @@ -339,8 +306,12 @@ def next_section(self, name: str, type_: str, skip_animations: bool) -> None: section_video: str | None = None # don't save when None 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}{self.output_spec.segment_extension}" + section_path = self.output_plan.section_path(len(self.sections), name) + assert self.output_plan.sections_dir is not None + # Section stores paths relative to its index file. + section_video = section_path.relative_to( + self.output_plan.sections_dir, + ).as_posix() self.sections.append( Section( @@ -375,44 +346,10 @@ def add_partial_movie_file(self, hash_animation: str | None) -> None: self.partial_movie_files.append(None) self.sections[-1].partial_movie_files.append(None) else: - new_partial_movie_file = str( - self.partial_movie_directory - / f"{hash_animation}{self.output_spec.segment_extension}" - ) + new_partial_movie_file = str(self.output_plan.segment_path(hash_animation)) self.partial_movie_files.append(new_partial_movie_file) self.sections[-1].partial_movie_files.append(new_partial_movie_file) - def get_resolution_directory(self) -> str: - """Get the name of the resolution directory directly containing - the video file. - - This method gets the name of the directory that immediately contains the - video file. This name is ``p``. - For example, if you are rendering an 854x480 px animation at 15fps, - the name of the directory that immediately contains the video, file - will be ``480p15``. - - The file structure should look something like:: - - MEDIA_DIR - |--Tex - |--texts - |--videos - |-- - |--p - |--partial_movie_files - |--.mp4 - |--.srt - - Returns - ------- - :class:`str` - The name of the directory. - """ - pixel_height = config["pixel_height"] - frame_rate = config["frame_rate"] - return f"{pixel_height}p{frame_rate}" - # Sound def init_audio(self) -> None: """Preps the writer for adding audio to the movie.""" @@ -578,20 +515,12 @@ def write_frame( if config.renderer == RendererType.OPENGL else Image.fromarray(frame_or_renderer) ) - target_dir = self.image_sequence_directory - extension = self.image_file_path.suffix - self.output_image( - image, - target_dir, - extension, - config["zero_pad"], - ) + self.output_image(image) - def output_image( - self, image: Image.Image, target_dir: StrPath, ext: str, zero_pad: int - ) -> None: - file_name = f"{self.frame_count:0{zero_pad}d}{ext}" - image.save(Path(target_dir) / file_name) + def output_image(self, image: Image.Image) -> None: + file_path = self.output_plan.image_frame_path(self.frame_count) + file_path.parent.mkdir(parents=True, exist_ok=True) + image.save(file_path) self.frame_count += 1 def save_image(self, image: Image.Image) -> None: @@ -604,9 +533,7 @@ def save_image(self, image: Image.Image) -> None: """ if not self.output_spec.enabled: return - if not config["output_file"]: - self.image_file_path = add_version_before_extension(self.image_file_path) - + self.image_file_path.parent.mkdir(parents=True, exist_ok=True) image.save(self.image_file_path) self.print_file_ready_message(self.image_file_path) @@ -645,6 +572,8 @@ def open_partial_movie_stream(self, file_path: StrPath | None = None) -> None: "open_partial_movie_stream() called for a play that has no " "partial movie file path.", ) + file_path = Path(file_path) + file_path.parent.mkdir(parents=True, exist_ok=True) path_key = str(file_path) if path_key in self._inflight_by_path: self._join_job_and_drain_on_failure(self._inflight_by_path[path_key]) @@ -816,10 +745,7 @@ def is_already_cached(self, hash_invocation: str) -> bool: or not self.output_spec.is_video ): return False - path = ( - self.partial_movie_directory - / f"{hash_invocation}{self.output_spec.segment_extension}" - ) + path = self.output_plan.segment_path(hash_invocation) path_key = str(path) if path_key in self._inflight_by_path: self._join_job_and_drain_on_failure(self._inflight_by_path[path_key]) @@ -832,7 +758,10 @@ def combine_files( create_gif: bool = False, includes_sound: bool = False, ) -> None: - file_list = self.partial_movie_directory / "partial_movie_file_list.txt" + file_list = self.output_plan.concat_manifest + assert file_list is not None + file_list.parent.mkdir(parents=True, exist_ok=True) + output_file.parent.mkdir(parents=True, exist_ok=True) logger.debug( f"Partial movie files to combine ({len(input_files)} files): %(p)s", {"p": input_files[:5]}, @@ -1050,12 +979,16 @@ def combine_to_section_videos(self) -> None: # only if section does want to be saved if section.video is not None: logger.info(f"Combining partial files for section '{section.name}'") + section_path = self.sections_output_dir / section.video self.combine_files( section.get_clean_partial_movie_files(), - self.sections_output_dir / section.video, + section_path, ) sections_index.append(section.get_dict(self.sections_output_dir)) - with (self.sections_output_dir / f"{self.output_name}.json").open("w") as file: + section_index = self.output_plan.section_index + assert section_index is not None + section_index.parent.mkdir(parents=True, exist_ok=True) + with section_index.open("w") as file: json.dump(sections_index, file, indent=4) def _cached_partial_movie_files(self) -> list[Path]: @@ -1068,6 +1001,8 @@ def _cached_partial_movie_files(self) -> list[Path]: ``max_files_cached`` and may vanish again before they could be deleted. """ + if not self.partial_movie_directory.exists(): + return [] return [ self.partial_movie_directory / file_name for file_name in self.partial_movie_directory.iterdir() @@ -1117,10 +1052,9 @@ def write_subcaption_file(self) -> None: """Writes the subcaption file next to the primary video artifact.""" if not self.output_spec.is_video: return - 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 = self.output_plan.subcaption_file + assert subcaption_file is not None + subcaption_file.parent.mkdir(parents=True, exist_ok=True) subcaption_file.write_text(srt.compose(self.subcaptions), encoding="utf-8") logger.info(f"Subcaption file has been written as {subcaption_file}") diff --git a/manim/utils/docbuild/manim_directive.py b/manim/utils/docbuild/manim_directive.py index ebe3cdefba..fa5e9c26ef 100644 --- a/manim/utils/docbuild/manim_directive.py +++ b/manim/utils/docbuild/manim_directive.py @@ -298,15 +298,21 @@ def run(self) -> list[nodes.Element]: code = [ "from manim import *", *user_code, - f"{clsname}().render()", + f"_manim_rendered_scene = {clsname}()", + "_manim_rendered_scene.render()", ] + render_namespace = globals() try: with tempconfig(example_config): - run_time = timeit(lambda: exec("\n".join(code), globals()), number=1) - video_dir = config.get_dir("video_dir") - images_dir = config.get_dir("images_dir") + run_time = timeit( + lambda: exec("\n".join(code), render_namespace), + number=1, + ) + rendered_scene = render_namespace.pop("_manim_rendered_scene") + filesrc = rendered_scene.renderer.file_writer.final_file_path except Exception as e: + render_namespace.pop("_manim_rendered_scene", None) raise RuntimeError(f"Error while rendering example {clsname}") from e _write_rendering_stats( @@ -317,18 +323,9 @@ def run(self) -> list[nodes.Element]: # copy video file to output directory if not (save_as_gif or save_last_frame): - filename = f"{output_file}.mp4" - filesrc = video_dir / filename + filename = filesrc.name destfile = Path(dest_dir, filename) shutil.copyfile(filesrc, destfile) - elif save_as_gif: - filename = f"{output_file}.gif" - filesrc = video_dir / filename - elif save_last_frame: - filename = f"{output_file}.png" - filesrc = images_dir / filename - else: - raise ValueError("Invalid combination of render flags received.") rendered_template = jinja2.Template(TEMPLATE).render( clsname=clsname, clsname_lowercase=clsname.lower(), diff --git a/manim/utils/ipython_magic.py b/manim/utils/ipython_magic.py index cb3040906b..7585768d11 100644 --- a/manim/utils/ipython_magic.py +++ b/manim/utils/ipython_magic.py @@ -9,7 +9,6 @@ from typing import Any from manim import config, logger, tempconfig -from manim.__main__ import main from manim.renderer.shader import shader_program_cache from ..constants import RendererType @@ -120,6 +119,10 @@ def construct(self): if cell: exec(cell, local_ns) + # Import lazily to keep package initialization independent of the CLI + # entry point and avoid a manim -> IPython magic -> CLI import cycle. + from manim.__main__ import main + args = line.split() if not len(args) or "-h" in args or "--help" in args or "--version" in args: main(args, standalone_mode=False, prog_name="manim") diff --git a/manim/utils/module_ops.py b/manim/utils/module_ops.py index e4c9374403..ddca750b2f 100644 --- a/manim/utils/module_ops.py +++ b/manim/utils/module_ops.py @@ -16,7 +16,6 @@ NO_SCENE_MESSAGE, SCENE_NOT_FOUND_MESSAGE, ) -from manim.scene.scene_file_writer import SceneFileWriter if TYPE_CHECKING: from manim.scene.scene import Scene @@ -112,7 +111,6 @@ def get_scenes_to_render(scene_classes: list[type[Scene]]) -> list[type[Scene]]: def prompt_user_for_choice(scene_classes: list[type[Scene]]) -> list[type[Scene]]: num_to_class = {} - SceneFileWriter.force_output_as_scene_name = True for count, scene_class in enumerate(scene_classes, 1): name = scene_class.__name__ console.print(f"{count}: {name}", style="logging.level.info") diff --git a/manim/utils/testing/_test_class_makers.py b/manim/utils/testing/_test_class_makers.py index b7b53306d3..40897fa2f0 100644 --- a/manim/utils/testing/_test_class_makers.py +++ b/manim/utils/testing/_test_class_makers.py @@ -53,9 +53,6 @@ def __init__( super().__init__(renderer, scene_name, **kwargs) self.i = 0 - def init_output_directories(self, scene_name: str) -> None: - pass - def add_partial_movie_file(self, hash_animation: str | None) -> None: pass diff --git a/tests/control_data/videos_data/SceneWithSections.json b/tests/control_data/videos_data/SceneWithSections.json index 5278526e57..1d9e8df912 100644 --- a/tests/control_data/videos_data/SceneWithSections.json +++ b/tests/control_data/videos_data/SceneWithSections.json @@ -12,7 +12,7 @@ "section_dir_layout": [ "SceneWithSections.json", "SceneWithSections_0004_unnamed.mp4", - "SceneWithSections_0003_Prepare For Unforeseen Consequences..mp4", + "SceneWithSections_0003_Prepare-For-Unforeseen-Consequences.mp4", "SceneWithSections_0002_test.mp4", "SceneWithSections_0001_unnamed.mp4", "SceneWithSections_0000_autocreated.mp4", @@ -58,7 +58,7 @@ { "name": "Prepare For Unforeseen Consequences.", "type": "default.normal", - "video": "SceneWithSections_0003_Prepare For Unforeseen Consequences..mp4", + "video": "SceneWithSections_0003_Prepare-For-Unforeseen-Consequences.mp4", "codec_name": "h264", "width": 854, "height": 480, diff --git a/tests/control_data/videos_data/SceneWithSkipAnimations.json b/tests/control_data/videos_data/SceneWithSkipAnimations.json index 71bb21abb3..2470682da3 100644 --- a/tests/control_data/videos_data/SceneWithSkipAnimations.json +++ b/tests/control_data/videos_data/SceneWithSkipAnimations.json @@ -11,16 +11,16 @@ }, "section_dir_layout": [ "ElaborateSceneWithSections.json", - "ElaborateSceneWithSections_0003_fade out.mp4", - "ElaborateSceneWithSections_0001_transform to circle.mp4", - "ElaborateSceneWithSections_0000_create square.mp4", + "ElaborateSceneWithSections_0003_fade-out.mp4", + "ElaborateSceneWithSections_0001_transform-to-circle.mp4", + "ElaborateSceneWithSections_0000_create-square.mp4", "." ], "section_index": [ { "name": "create square", "type": "default.normal", - "video": "ElaborateSceneWithSections_0000_create square.mp4", + "video": "ElaborateSceneWithSections_0000_create-square.mp4", "codec_name": "h264", "width": 854, "height": 480, @@ -32,7 +32,7 @@ { "name": "transform to circle", "type": "default.normal", - "video": "ElaborateSceneWithSections_0001_transform to circle.mp4", + "video": "ElaborateSceneWithSections_0001_transform-to-circle.mp4", "codec_name": "h264", "width": 854, "height": 480, @@ -44,7 +44,7 @@ { "name": "fade out", "type": "default.normal", - "video": "ElaborateSceneWithSections_0003_fade out.mp4", + "video": "ElaborateSceneWithSections_0003_fade-out.mp4", "codec_name": "h264", "width": 854, "height": 480, diff --git a/tests/module/test_output_path_behavior.py b/tests/module/test_output_path_behavior.py new file mode 100644 index 0000000000..c71f18c6ab --- /dev/null +++ b/tests/module/test_output_path_behavior.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock + +import pytest +from PIL import Image + +from manim import __version__ +from manim._config.output import OutputFormat, OutputSpec +from manim._config.output_plan import ( + resolve_media_layout, + resolve_module_name, + resolve_output_plan, + resolve_requested_output_name, +) +from manim.scene.scene_file_writer import SceneFileWriter + + +def _make_writer( + config, + tmp_path: Path, + output_format: OutputFormat, + *, + transparent: bool = False, + save_sections: bool = False, + fallback_to_still: bool = False, + output_file: str | Path = "", +) -> SceneFileWriter: + config.media_dir = tmp_path + config.input_file = tmp_path / "nested" / "example.scene.py" + config.pixel_height = 480 + config.frame_rate = 15 + config.output_file = output_file + + output = OutputSpec( + output_format, + transparent, + save_sections, + fallback_to_still, + ) + module_name = resolve_module_name(config) + layout = resolve_media_layout( + config, + output, + module_name=module_name, + scene_name="ExampleScene", + working_directory=Path.cwd(), + ) + output_plan = resolve_output_plan( + layout, + output, + scene_name="ExampleScene", + requested_output_name=resolve_requested_output_name(config), + ) + renderer = Mock() + renderer.num_plays = 0 + return SceneFileWriter( + renderer, + "ExampleScene", + output, + output_plan, + ) + + +@pytest.mark.parametrize( + ("output_format", "extension"), + [ + (OutputFormat.MP4, ".mp4"), + (OutputFormat.MOV, ".mov"), + (OutputFormat.WEBM, ".webm"), + ], +) +def test_default_video_paths(config, tmp_path, output_format, extension): + writer = _make_writer(config, tmp_path, output_format) + quality_dir = tmp_path / "videos" / "example.scene" / "480p15" + + assert writer.movie_file_path == quality_dir / f"ExampleScene{extension}" + assert writer.partial_movie_directory == ( + quality_dir / "partial_movie_files" / "ExampleScene" + ) + + +@pytest.mark.parametrize( + ("transparent", "segment_extension"), + [(False, ".mp4"), (True, ".mov")], +) +def test_gif_primary_and_segment_paths( + config, + tmp_path, + transparent, + segment_extension, +): + writer = _make_writer( + config, + tmp_path, + OutputFormat.GIF, + transparent=transparent, + ) + quality_dir = tmp_path / "videos" / "example.scene" / "480p15" + + assert writer.movie_file_path == ( + quality_dir / f"ExampleScene_ManimCE_v{__version__}.gif" + ) + assert writer.gif_file_path == ( + quality_dir / f"ExampleScene_ManimCE_v{__version__}.gif" + ) + writer.add_partial_movie_file("cache-key") + assert writer.partial_movie_files == [ + str( + quality_dir + / "partial_movie_files" + / "ExampleScene" + / f"cache-key{segment_extension}" + ) + ] + + +def test_default_png_and_automatic_video_fallback_paths(config, tmp_path): + png_writer = _make_writer(config, tmp_path, OutputFormat.PNG) + expected = ( + tmp_path + / "images" + / "example.scene" + / f"ExampleScene_ManimCE_v{__version__}.png" + ) + + png_writer.save_image(Image.new("RGBA", (1, 1))) + assert png_writer.final_file_path == expected + + video_writer = _make_writer( + config, + tmp_path, + OutputFormat.MP4, + fallback_to_still=True, + ) + video_writer.save_image(Image.new("RGBA", (1, 1))) + assert video_writer.final_file_path == expected + + +def test_png_sequence_path_and_zero_padding(config, tmp_path): + config.zero_pad = 3 + writer = _make_writer(config, tmp_path, OutputFormat.PNG_SEQUENCE) + + expected_dir = tmp_path / "images" / "example.scene" / "ExampleScene" + assert writer.image_sequence_directory == expected_dir + + writer.output_image(Image.new("RGBA", (1, 1))) + assert (expected_dir / "000.png").is_file() + + +def test_resolved_output_suffix_preserves_a_different_suffix(config, tmp_path): + matching = _make_writer( + config, + tmp_path, + OutputFormat.MP4, + output_file="movie.mp4", + ) + assert matching.movie_file_path.name == "movie.mp4" + + differing = _make_writer( + config, + tmp_path, + OutputFormat.MP4, + output_file="movie.mov", + ) + assert differing.movie_file_path.name == "movie.mov.mp4" + + +def test_sections_use_configured_directory_for_simple_output_name(config, tmp_path): + writer = _make_writer( + config, + tmp_path, + OutputFormat.MP4, + save_sections=True, + output_file="movie", + ) + writer.next_section("intro", skip_animations=False, type_="default.normal") + + section = writer.sections[-1] + assert writer.sections_output_dir == ( + tmp_path / "videos" / "example.scene" / "480p15" / "sections" + ) + assert section.video == "movie_0000_intro.mp4" + assert writer.sections_output_dir / section.video == ( + writer.sections_output_dir / "movie_0000_intro.mp4" + ) + + +def test_nested_and_absolute_output_names_do_not_relocate_sections( + config, + tmp_path, +): + nested = _make_writer( + config, + tmp_path, + OutputFormat.MP4, + save_sections=True, + output_file="exports/movie", + ) + nested.next_section("intro", skip_animations=False, type_="default.normal") + assert nested.sections_output_dir / nested.sections[-1].video == ( + nested.sections_output_dir / "movie_0000_intro.mp4" + ) + + absolute_name = tmp_path / "exports" / "movie" + absolute = _make_writer( + config, + tmp_path, + OutputFormat.MP4, + save_sections=True, + output_file=absolute_name, + ) + absolute.next_section("intro", skip_animations=False, type_="default.normal") + assert not Path(absolute.sections[-1].video).is_absolute() + assert absolute.sections_output_dir / absolute.sections[-1].video == ( + absolute.sections_output_dir / "movie_0000_intro.mp4" + ) + + +def test_no_output_plans_no_media_directories(config, tmp_path): + media_root = tmp_path / "unused-media" + writer = _make_writer(config, media_root, OutputFormat.NONE) + + assert not hasattr(writer, "movie_file_path") + assert not hasattr(writer, "image_file_path") + assert not media_root.exists() diff --git a/tests/module/test_output_plan.py b/tests/module/test_output_plan.py new file mode 100644 index 0000000000..c49660031f --- /dev/null +++ b/tests/module/test_output_plan.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +from manim import Scene, __version__ +from manim._config.output import OutputFormat, OutputSpec +from manim._config.output_plan import ( + MediaLayoutSpec, + resolve_file_log_path, + resolve_media_layout, + resolve_module_name, + resolve_output_plan, + resolve_requested_output_name, +) +from manim.cli.render.commands import _validate_scene_batch_output_name + + +def _layout(tmp_path: Path, *, sections: bool = False) -> MediaLayoutSpec: + root = tmp_path / "not-created" + return MediaLayoutSpec( + video_dir=root / "videos", + images_dir=root / "images", + sections_dir=root / "sections" if sections else None, + partial_movie_dir=root / "segments", + log_dir=root / "logs", + zero_pad=4, + ) + + +def _output( + output_format: OutputFormat, + *, + transparent: bool = False, + save_sections: bool = False, + fallback_to_still: bool = False, +) -> OutputSpec: + return OutputSpec( + output_format, + transparent, + save_sections, + fallback_to_still, + ) + + +@pytest.mark.parametrize( + ("output_format", "extension"), + [ + (OutputFormat.MP4, ".mp4"), + (OutputFormat.MOV, ".mov"), + (OutputFormat.WEBM, ".webm"), + ], +) +def test_resolve_video_plan(tmp_path, output_format, extension): + layout = _layout(tmp_path) + + plan = resolve_output_plan( + layout, + _output(output_format), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.primary_artifact == layout.video_dir / f"ExampleScene{extension}" + assert plan.fallback_image is None + assert plan.segment_cache_dir == layout.partial_movie_dir + assert plan.segment_path("cache-key") == ( + layout.partial_movie_dir / f"cache-key{extension}" + ) + assert plan.concat_manifest == ( + layout.partial_movie_dir / "partial_movie_file_list.txt" + ) + assert plan.subcaption_file == layout.video_dir / "ExampleScene.srt" + + +@pytest.mark.parametrize( + ("transparent", "segment_extension"), + [(False, ".mp4"), (True, ".mov")], +) +def test_resolve_gif_plan(tmp_path, transparent, segment_extension): + layout = _layout(tmp_path) + + plan = resolve_output_plan( + layout, + _output(OutputFormat.GIF, transparent=transparent), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.primary_artifact == ( + layout.video_dir / f"ExampleScene_ManimCE_v{__version__}.gif" + ) + assert plan.segment_extension == segment_extension + assert plan.segment_path("hash") == layout.partial_movie_dir / ( + f"hash{segment_extension}" + ) + + +def test_resolve_automatic_video_plan_with_fallback(tmp_path): + layout = _layout(tmp_path) + + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4, fallback_to_still=True), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.primary_artifact == layout.video_dir / "ExampleScene.mp4" + assert plan.fallback_image == ( + layout.images_dir / f"ExampleScene_ManimCE_v{__version__}.png" + ) + + +def test_resolve_png_plan(tmp_path): + layout = _layout(tmp_path) + + plan = resolve_output_plan( + layout, + _output(OutputFormat.PNG), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.primary_artifact == ( + layout.images_dir / f"ExampleScene_ManimCE_v{__version__}.png" + ) + assert plan.fallback_image is None + with pytest.raises(ValueError, match="does not contain an image sequence"): + plan.image_frame_path(0) + + +def test_resolve_png_sequence_plan(tmp_path): + layout = _layout(tmp_path) + + plan = resolve_output_plan( + layout, + _output(OutputFormat.PNG_SEQUENCE), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.primary_artifact == layout.images_dir / "ExampleScene" + assert plan.image_sequence_dir == layout.images_dir / "ExampleScene" + assert plan.image_frame_path(0) == layout.images_dir / "ExampleScene" / "0000.png" + assert plan.image_frame_path(42) == ( + layout.images_dir / "ExampleScene" / "0042.png" + ) + + +def test_resolve_no_output_plan_without_layout_directories(tmp_path): + layout = MediaLayoutSpec(None, None, None, None, None, zero_pad=4) + + plan = resolve_output_plan( + layout, + _output(OutputFormat.NONE), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.primary_artifact is None + assert plan.fallback_image is None + assert plan.segment_cache_dir is None + assert plan.image_sequence_dir is None + assert not (tmp_path / "not-created").exists() + + +@pytest.mark.parametrize( + ("requested_name", "expected_name"), + [ + ("movie", "movie.mp4"), + ("movie.mp4", "movie.mp4"), + ("movie.mov", "movie.mov.mp4"), + ], +) +def test_resolved_format_controls_custom_output_suffix( + tmp_path, + requested_name, + expected_name, +): + layout = _layout(tmp_path) + + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4), + scene_name="ExampleScene", + requested_output_name=Path(requested_name), + ) + + assert plan.primary_artifact == layout.video_dir / expected_name + assert plan.output_stem == "movie" + + +def test_absolute_output_name_only_relocates_primary_and_fallback(tmp_path): + layout = _layout(tmp_path, sections=True) + requested = tmp_path / "exports" / "movie.mov" + + plan = resolve_output_plan( + layout, + _output( + OutputFormat.MP4, + save_sections=True, + fallback_to_still=True, + ), + scene_name="ExampleScene", + requested_output_name=requested, + ) + + assert plan.primary_artifact == tmp_path / "exports" / "movie.mov.mp4" + assert plan.fallback_image == tmp_path / "exports" / "movie.mov.png" + assert plan.section_index == layout.sections_dir / "movie.json" + assert plan.section_path(0, "intro") == ( + layout.sections_dir / "movie_0000_intro.mp4" + ) + assert plan.segment_cache_dir == layout.partial_movie_dir + + +def test_nested_output_name_keeps_sections_in_configured_directory(tmp_path): + layout = _layout(tmp_path, sections=True) + + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4, save_sections=True), + scene_name="ExampleScene", + requested_output_name=Path("exports/movie.mp4"), + ) + + assert plan.primary_artifact == layout.video_dir / "exports" / "movie.mp4" + assert plan.section_path(3, "ending") == ( + layout.sections_dir / "movie_0003_ending.mp4" + ) + + +def test_plan_resolution_does_not_create_directories(tmp_path): + layout = _layout(tmp_path, sections=True) + missing_root = tmp_path / "not-created" + + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4, save_sections=True), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.primary_artifact is not None + assert not missing_root.exists() + + +def test_plans_are_immutable_hashable_values(tmp_path): + layout = _layout(tmp_path) + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert hash(layout) + assert hash(plan) + with pytest.raises(FrozenInstanceError): + plan.primary_artifact = tmp_path / "other.mp4" + + +@pytest.mark.parametrize("scene_name", ["", None]) +def test_scene_name_is_required(tmp_path, scene_name): + with pytest.raises(ValueError, match="scene name"): + resolve_output_plan( + _layout(tmp_path), + _output(OutputFormat.MP4), + scene_name=scene_name, + requested_output_name=None, + ) + + +def test_dynamic_path_methods_validate_inputs(tmp_path): + layout = _layout(tmp_path, sections=True) + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4, save_sections=True), + scene_name="ExampleScene", + requested_output_name=None, + ) + + with pytest.raises(ValueError, match="cache key"): + plan.segment_path("../escape") + with pytest.raises(ValueError, match="non-negative"): + plan.section_path(-1, "intro") + with pytest.raises(TypeError, match="strings"): + plan.section_path(0, 1) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("name", "slug"), + [ + ("1", "1"), + ("create square", "create-square"), + ("Chapter 1: Why/How?", "Chapter-1-Why-How"), + ("../../../escape", "escape"), + ("Überblick № 2", "Überblick-No-2"), + ("!!!", "section"), + ], +) +def test_section_paths_use_safe_human_readable_slugs(tmp_path, name, slug): + layout = _layout(tmp_path, sections=True) + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4, save_sections=True), + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert plan.section_path(3, name) == ( + layout.sections_dir / f"ExampleScene_0003_{slug}.mp4" + ) + + +def test_section_ordinal_keeps_duplicate_slugs_unique(tmp_path): + layout = _layout(tmp_path, sections=True) + plan = resolve_output_plan( + layout, + _output(OutputFormat.MP4, save_sections=True), + scene_name="ExampleScene", + requested_output_name=None, + ) + + first = plan.section_path(1, "intro!") + second = plan.section_path(2, "intro?") + + assert first.name == "ExampleScene_0001_intro.mp4" + assert second.name == "ExampleScene_0002_intro.mp4" + assert first != second + + +def test_config_adapter_captures_exact_required_directories(config, tmp_path): + config.media_dir = "relative-media" + config.input_file = tmp_path / "source" / "example.py" + config.pixel_height = 480 + config.frame_rate = 15 + config.zero_pad = 3 + config.log_to_file = True + output = _output( + OutputFormat.MP4, + save_sections=True, + fallback_to_still=True, + ) + + module_name = resolve_module_name(config) + layout = resolve_media_layout( + config, + output, + module_name=module_name, + scene_name="ExampleScene", + working_directory=tmp_path, + ) + + quality_dir = tmp_path / "relative-media" / "videos" / "example" / "480p15" + assert module_name == "example" + assert layout.video_dir == quality_dir + assert layout.images_dir == tmp_path / "relative-media" / "images" / "example" + assert layout.sections_dir == quality_dir / "sections" + assert layout.partial_movie_dir == ( + quality_dir / "partial_movie_files" / "ExampleScene" + ) + assert layout.log_dir == tmp_path / "relative-media" / "logs" + assert layout.zero_pad == 3 + assert not (tmp_path / "relative-media").exists() + + +def test_explicit_video_skips_the_unused_fallback_directory(config, tmp_path): + config.media_dir = "explicit-video" + config.images_dir = "{unused_placeholder}" + output = _output(OutputFormat.MP4) + + layout = resolve_media_layout( + config, + output, + module_name="example", + scene_name="ExampleScene", + working_directory=tmp_path, + ) + plan = resolve_output_plan( + layout, + output, + scene_name="ExampleScene", + requested_output_name=None, + ) + + assert layout.images_dir is None + assert layout.video_dir is not None + assert plan.fallback_image is None + + +def test_config_adapter_skips_unused_output_directories(config, tmp_path): + config.media_dir = "unused-media" + config.log_to_file = False + + layout = resolve_media_layout( + config, + _output(OutputFormat.NONE), + module_name="", + scene_name="ExampleScene", + working_directory=tmp_path, + ) + + assert layout == MediaLayoutSpec(None, None, None, None, None, zero_pad=4) + + +def test_scene_and_writer_share_immutable_output_plan(config, tmp_path): + initial_media_dir = tmp_path / "initial" + config.media_dir = initial_media_dir + config.input_file = tmp_path / "example.py" + config.format = "mp4" + + scene = Scene() + plan = scene.output_plan + + assert not initial_media_dir.exists() + config.media_dir = tmp_path / "changed" + config.output_file = "changed-name" + + assert scene.renderer.file_writer.output_plan is plan + assert plan.primary_artifact == ( + initial_media_dir / "videos" / "example" / "1080p60" / "Scene.mp4" + ) + + +def test_cli_batch_output_name_validation(config): + config.output_file = "movie" + config.write_all = False + + _validate_scene_batch_output_name([object]) + with pytest.raises(ValueError, match="exactly one scene"): + _validate_scene_batch_output_name([object, object]) + + config.write_all = True + with pytest.raises(ValueError, match="exactly one scene"): + _validate_scene_batch_output_name([object]) + + +def test_requested_name_and_log_path_resolution(config, tmp_path): + config.output_file = "exports/movie.mp4" + config.log_to_file = True + config.media_dir = tmp_path + module_name = resolve_module_name(config) + layout = resolve_media_layout( + config, + _output(OutputFormat.NONE), + module_name=module_name, + scene_name="ExampleScene", + working_directory=tmp_path, + ) + + assert resolve_requested_output_name(config) == Path("exports/movie.mp4") + assert ( + resolve_file_log_path( + layout, + module_name=module_name, + scene_name="ExampleScene", + ) + == tmp_path / "logs" / "_ExampleScene.log" + ) diff --git a/tests/test_config.py b/tests/test_config.py index 4d078fd106..aab4270dcb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -16,7 +16,7 @@ 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 +from tests.assert_utils import assert_dir_filled, assert_file_exists def _resolve_session(config): @@ -379,8 +379,7 @@ def test_custom_dirs(tmp_path, config): assert_dir_filled(tmp_path / "test_partial_movie_dir") assert_file_exists(tmp_path / "test_partial_movie_dir/partial_movie_file_list.txt") - # TODO: another example with image output would be nice - assert_dir_exists(tmp_path / "test_images") + assert not (tmp_path / "test_images").exists() assert_dir_filled(tmp_path / "test_text") assert_dir_filled(tmp_path / "test_tex") diff --git a/tests/test_logging/test_logging.py b/tests/test_logging/test_logging.py index 397573a51e..edf13aafe2 100644 --- a/tests/test_logging/test_logging.py +++ b/tests/test_logging/test_logging.py @@ -30,6 +30,29 @@ def test_logging_to_file(tmp_path, python_version): assert exitcode == 0, err +def test_library_logging_without_media_output(tmp_path, python_version): + script = f""" +from manim import Scene, tempconfig + +class LibraryScene(Scene): + pass + +with tempconfig({{ + "format": "none", + "log_to_file": True, + "media_dir": {str(tmp_path)!r}, +}}): + LibraryScene().render() +""" + + _, err, exitcode = capture([python_version, "-c", script]) + + assert exitcode == 0, err + assert (tmp_path / "logs" / "_LibraryScene.log").is_file() + assert not (tmp_path / "videos").exists() + assert not (tmp_path / "images").exists() + + def test_error_logging(tmp_path, python_version): path_error_scene = Path("tests/test_logging/basic_scenes_error.py") 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 c4bb36293a..ac8079162f 100644 --- a/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py +++ b/tests/test_scene_rendering/opengl/test_cli_flags_opengl.py @@ -209,8 +209,9 @@ def test_no_image_output_with_interactive_embed( "running an interactive static scene rendered a video" ) - is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) - assert is_empty, "running an interactive static scene rendered an image" + assert not (tmp_path / "images").exists(), ( + "running an interactive static scene rendered an image" + ) @pytest.mark.slow @@ -240,8 +241,9 @@ def test_default_video_output_with_non_static_scene( "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, "default video output unexpectedly rendered an image" + assert not (tmp_path / "images").exists(), ( + "default video output unexpectedly rendered an image" + ) @pytest.mark.slow @@ -361,33 +363,6 @@ def test_a_flag(tmp_path, manim_cfg_file, infallible_scenes_path): ) -@pytest.mark.slow -def test_custom_folders(tmp_path, manim_cfg_file, simple_scenes_path): - scene_name = "SquareToCircle" - command = [ - sys.executable, - "-m", - "manim", - "--renderer", - "opengl", - "-ql", - "-s", - "--media_dir", - str(tmp_path), - "--custom_folders", - str(simple_scenes_path), - scene_name, - ] - out, err, exit_code = capture(command) - assert exit_code == 0, err - - exists = (tmp_path / "videos").exists() - assert not exists, "--custom_folders produced a 'videos/' dir" - - exists = add_version_before_extension(tmp_path / "SquareToCircle.png").exists() - assert exists, "--custom_folders did not produce the output file" - - @pytest.mark.slow def test_dash_as_filename(tmp_path): code = ( diff --git a/tests/test_scene_rendering/test_cli_flags.py b/tests/test_scene_rendering/test_cli_flags.py index f81c87dae2..ba9f38e904 100644 --- a/tests/test_scene_rendering/test_cli_flags.py +++ b/tests/test_scene_rendering/test_cli_flags.py @@ -253,29 +253,39 @@ def test_a_flag(tmp_path, manim_cfg_file, infallible_scenes_path): ) -@pytest.mark.slow -def test_custom_folders(tmp_path, manim_cfg_file, simple_scenes_path): - scene_name = "SquareToCircle" +@pytest.mark.parametrize( + "scene_selection", + [ + ("-a",), + ("Wait1", "Wait3"), + ], +) +def test_output_file_rejects_multi_scene_render( + tmp_path, + infallible_scenes_path, + scene_selection, +): command = [ sys.executable, "-m", "manim", - "-ql", - "-s", "--media_dir", str(tmp_path), - "--custom_folders", - str(simple_scenes_path), - scene_name, + "-o", + "shared-name", ] - out, err, exit_code = capture(command) - assert exit_code == 0, err + if scene_selection == ("-a",): + command.extend(["-a", str(infallible_scenes_path)]) + else: + command.extend([str(infallible_scenes_path), *scene_selection]) - exists = (tmp_path / "videos").exists() - assert not exists, "--custom_folders produced a 'videos/' dir" + out, err, exit_code = capture(command) - exists = add_version_before_extension(tmp_path / "SquareToCircle.png").exists() - assert exists, "--custom_folders did not produce the output file" + assert exit_code == 1 + assert "--output_file can only be used when rendering exactly one scene" in ( + err or out + ) + assert not tmp_path.exists() or not any(tmp_path.iterdir()) @pytest.mark.slow @@ -324,7 +334,8 @@ def test_custom_output_name_gif(tmp_path, simple_scenes_path): @pytest.mark.slow def test_custom_output_name_mp4(tmp_path, simple_scenes_path): scene_name = "SquareToCircle" - custom_name = "custom_name" + requested_name = "custom_name.mov" + expected_name = requested_name command = [ sys.executable, "-m", @@ -333,7 +344,7 @@ def test_custom_output_name_mp4(tmp_path, simple_scenes_path): "--media_dir", str(tmp_path), "-o", - custom_name, + requested_name, str(simple_scenes_path), scene_name, ] @@ -345,18 +356,18 @@ def test_custom_output_name_mp4(tmp_path, simple_scenes_path): ) assert not wrong_mp4_path.exists(), ( - "The mp4 file does not respect the custom name: " + custom_name + ".mp4" + "The mp4 file does not respect the custom name: " + expected_name + ".mp4" ) unexpected_gif_path = add_version_before_extension( - tmp_path / "videos" / "simple_scenes" / "480p15" / f"{custom_name}.gif" + tmp_path / "videos" / "simple_scenes" / "480p15" / f"{expected_name}.gif" ) assert not unexpected_gif_path.exists(), "Found an unexpected gif file at " + str( unexpected_gif_path ) expected_mp4_path = ( - tmp_path / "videos" / "simple_scenes" / "480p15" / str(custom_name + ".mp4") + tmp_path / "videos" / "simple_scenes" / "480p15" / str(expected_name + ".mp4") ) assert expected_mp4_path.exists(), "mp4 file not found at " + str(expected_mp4_path) diff --git a/tests/test_scene_rendering/test_file_writer.py b/tests/test_scene_rendering/test_file_writer.py index 66d49feb0b..c44bbbed7b 100644 --- a/tests/test_scene_rendering/test_file_writer.py +++ b/tests/test_scene_rendering/test_file_writer.py @@ -8,7 +8,14 @@ import pytest from manim import DR, Circle, Create, Scene, Star, tempconfig +from manim._config import config from manim._config.output import OutputFormat, OutputSpec +from manim._config.output_plan import ( + resolve_media_layout, + resolve_module_name, + resolve_output_plan, + resolve_requested_output_name, +) from manim.scene.scene_file_writer import SceneFileWriter, to_av_frame_rate from manim.utils.commands import capture, get_video_metadata @@ -192,16 +199,27 @@ def test_frame_rates(): def _new_file_writer(scene_name: str) -> SceneFileWriter: renderer = Mock() renderer.num_plays = 0 - return SceneFileWriter( - renderer, - scene_name, - OutputSpec( - OutputFormat.MP4, - transparent=False, - save_sections=False, - fallback_to_still=False, - ), + output = OutputSpec( + OutputFormat.MP4, + transparent=False, + save_sections=False, + fallback_to_still=False, + ) + module_name = resolve_module_name(config) + layout = resolve_media_layout( + config, + output, + module_name=module_name, + scene_name=scene_name, + working_directory=Path.cwd(), + ) + plan = resolve_output_plan( + layout, + output, + scene_name=scene_name, + requested_output_name=resolve_requested_output_name(config), ) + return SceneFileWriter(renderer, scene_name, output, plan) def test_clean_cache_ignores_hidden_files(config, tmp_path): @@ -211,6 +229,7 @@ def test_clean_cache_ignores_hidden_files(config, tmp_path): with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("CacheCleaningScene") cache_dir = writer.partial_movie_directory + cache_dir.mkdir(parents=True) for name in ["00001.mp4", ".DS_Store", "._00001.mp4"]: (cache_dir / name).touch() @@ -236,6 +255,7 @@ def test_flush_cache_directory_ignores_hidden_files(config, tmp_path): with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("CacheFlushingScene") cache_dir = writer.partial_movie_directory + cache_dir.mkdir(parents=True) for name in ["00001.mp4", "00002.mp4", ".DS_Store", "._00001.mp4"]: (cache_dir / name).touch() @@ -257,6 +277,7 @@ def test_clean_cache_tolerates_vanishing_files(config, tmp_path, monkeypatch): with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("VanishingFileScene") cache_dir = writer.partial_movie_directory + cache_dir.mkdir(parents=True) survivor = cache_dir / "00001.mp4" survivor.touch() @@ -275,6 +296,7 @@ def test_clean_cache_does_not_evict_for_vanished_file(config, tmp_path, monkeypa with tempconfig({"media_dir": tmp_path, "format": "mp4"}): writer = _new_file_writer("VanishedFileEvictionScene") cache_dir = writer.partial_movie_directory + cache_dir.mkdir(parents=True) survivors = [cache_dir / f"{index:05}.mp4" for index in range(2)] for survivor in survivors: diff --git a/tests/test_scene_rendering/test_parallel_encoding.py b/tests/test_scene_rendering/test_parallel_encoding.py index 43183fc078..acf0347dc4 100644 --- a/tests/test_scene_rendering/test_parallel_encoding.py +++ b/tests/test_scene_rendering/test_parallel_encoding.py @@ -16,7 +16,14 @@ from manim import FadeIn, Scene, Square, capture, tempconfig from manim._config import config from manim._config.output import OutputFormat, OutputSpec +from manim._config.output_plan import ( + resolve_media_layout, + resolve_module_name, + resolve_output_plan, + resolve_requested_output_name, +) from manim.cli.render.commands import render +from manim.scene.scene_file_writer import SceneFileWriter from manim.utils.exceptions import RerunSceneException _ENCODER_THREAD_PREFIX = "partial-movie-encoder-" @@ -35,6 +42,29 @@ _UNIQUE_PLAYS = 6 _TOTAL_PLAYS = _UNIQUE_PLAYS + 2 + +def _make_writer( + scene_name: str, + output: OutputSpec = _VIDEO_OUTPUT, +) -> SceneFileWriter: + module_name = resolve_module_name(config) + layout = resolve_media_layout( + config, + output, + module_name=module_name, + scene_name=scene_name, + working_directory=Path.cwd(), + ) + plan = resolve_output_plan( + layout, + output, + scene_name=scene_name, + requested_output_name=resolve_requested_output_name(config), + ) + renderer = Mock(num_plays=0) + return SceneFileWriter(renderer, scene_name, output, plan) + + _SCENE_NAME = "ParallelEncodingCacheScene" _SCENE_SOURCE = textwrap.dedent( f"""\ @@ -341,8 +371,6 @@ def test_write_frame_fails_fast_after_encoder_failure( tmp_path, monkeypatch, ): - from manim.scene.scene_file_writer import SceneFileWriter - expected_exception = RuntimeError("encode failed") stream = Mock() container = Mock() @@ -354,9 +382,7 @@ def encode(*args): stream.encode.side_effect = encode config.media_dir = str(tmp_path) - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "FailFastScene", _VIDEO_OUTPUT) + writer = _make_writer("FailFastScene") job = _new_encode_job(tmp_path, monkeypatch, "fail_fast", stream, container) job.path.write_bytes(b"stale") writer._current_encode_job = job @@ -396,13 +422,9 @@ def test_frame_queue_configuration( encoder_queue_size, expected_queue_size, ): - from manim.scene.scene_file_writer import SceneFileWriter - config.max_inflight_encoders = max_inflight_encoders config.encoder_queue_size = encoder_queue_size - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "FrameQueueSizeScene", _VIDEO_OUTPUT) + writer = _make_writer("FrameQueueSizeScene") writer.open_partial_movie_stream(tmp_path / "partial.mp4") job = writer._current_encode_job @@ -420,12 +442,8 @@ def test_close_partial_movie_stream_respects_cap_and_joins_fifo( tmp_path, max_inflight_encoders, ): - from manim.scene.scene_file_writer import SceneFileWriter - config.max_inflight_encoders = max_inflight_encoders - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "EncoderCapScene", _VIDEO_OUTPUT) + writer = _make_writer("EncoderCapScene") jobs = [Mock(path=tmp_path / f"partial_{index}.mp4") for index in range(3)] for index, job in enumerate(jobs): @@ -456,14 +474,10 @@ def test_close_partial_movie_stream_respects_cap_and_joins_fifo( def test_cap_join_failure_drains_all_inflight_jobs(config, tmp_path): - from manim.scene.scene_file_writer import SceneFileWriter - primary_exception = RuntimeError("first join failed") secondary_exception = RuntimeError("second join failed") config.max_inflight_encoders = 3 - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "EncoderCapFailureScene", _VIDEO_OUTPUT) + writer = _make_writer("EncoderCapFailureScene") 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 @@ -486,11 +500,7 @@ def test_cap_join_failure_drains_all_inflight_jobs(config, tmp_path): def test_is_already_cached_joins_same_path_inflight_job(config, tmp_path): - from manim.scene.scene_file_writer import SceneFileWriter - - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedInflightScene", _VIDEO_OUTPUT) + writer = _make_writer("CachedInflightScene") hash_invocation = "same_path_hash" path = ( writer.partial_movie_directory @@ -508,12 +518,8 @@ def test_is_already_cached_joins_same_path_inflight_job(config, tmp_path): def test_same_path_join_failure_drains_unrelated_jobs(config, tmp_path): - from manim.scene.scene_file_writer import SceneFileWriter - expected_exception = RuntimeError("same-path join failed") - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedInflightFailureScene", _VIDEO_OUTPUT) + writer = _make_writer("CachedInflightFailureScene") hash_invocation = "failing_same_path_hash" path = ( writer.partial_movie_directory @@ -537,11 +543,7 @@ def test_same_path_join_failure_drains_unrelated_jobs(config, tmp_path): def test_open_partial_movie_stream_joins_same_path_inflight_job(config, tmp_path): - from manim.scene.scene_file_writer import SceneFileWriter - - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "OpenInflightScene", _VIDEO_OUTPUT) + writer = _make_writer("OpenInflightScene") path = tmp_path / "same_path.mp4" inflight_job = Mock(path=path) writer._inflight_encode_jobs.append(inflight_job) @@ -567,12 +569,8 @@ def test_finish_propagates_join_failure_and_clears_inflight_state( tmp_path, monkeypatch, ): - from manim.scene.scene_file_writer import SceneFileWriter - expected_exception = RuntimeError("join failed") - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "JoinFailureScene", _VIDEO_OUTPUT) + writer = _make_writer("JoinFailureScene") failing_job = Mock(path=tmp_path / "failing.mp4") failing_job.join.side_effect = expected_exception succeeding_job = Mock(path=tmp_path / "succeeding.mp4") @@ -594,12 +592,8 @@ def test_finish_propagates_join_failure_and_clears_inflight_state( def _new_writer(config, tmp_path, scene_name): - from manim.scene.scene_file_writer import SceneFileWriter - config.media_dir = str(tmp_path) - renderer = Mock() - renderer.num_plays = 0 - return SceneFileWriter(renderer, scene_name, _VIDEO_OUTPUT) + return _make_writer(scene_name) def _healthy_current_job(tmp_path, monkeypatch, name): @@ -684,12 +678,8 @@ def test_abort_encode_jobs_cleanup_failure_logs_warning( def test_abort_encode_jobs_noop_on_dry_run_writer(config): - from manim.scene.scene_file_writer import SceneFileWriter - with tempconfig({"dry_run": True}): - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "DryRunAbortScene", _NO_OUTPUT) + writer = _make_writer("DryRunAbortScene", _NO_OUTPUT) writer.abort_encode_jobs() writer.abort_encode_jobs(reraise_encoder_failures=True) @@ -1025,12 +1015,8 @@ def test_is_already_cached_false_after_joining_failed_path(config, tmp_path): The existing same-path test asserts the join happens but never checks the return value. """ - from manim.scene.scene_file_writer import SceneFileWriter - with tempconfig({"media_dir": str(tmp_path)}): - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedReturnScene", _VIDEO_OUTPUT) + writer = _make_writer("CachedReturnScene") hash_invocation = "missing_partial_hash" path = ( writer.partial_movie_directory @@ -1045,41 +1031,30 @@ def test_is_already_cached_false_after_joining_failed_path(config, tmp_path): def test_is_already_cached_true_when_partial_exists(config, tmp_path): - from manim.scene.scene_file_writer import SceneFileWriter - with tempconfig({"media_dir": str(tmp_path)}): - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "CachedReturnScene", _VIDEO_OUTPUT) + writer = _make_writer("CachedReturnScene") hash_invocation = "present_partial_hash" path = ( writer.partial_movie_directory / f"{hash_invocation}{writer.output_spec.segment_extension}" ) + path.parent.mkdir(parents=True) path.write_bytes(b"cached partial") assert writer.is_already_cached(hash_invocation) is True def test_close_partial_movie_stream_without_open_stream_raises(config, tmp_path): - from manim.scene.scene_file_writer import SceneFileWriter - with tempconfig({"media_dir": str(tmp_path)}): - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "GuardScene", _VIDEO_OUTPUT) + writer = _make_writer("GuardScene") with pytest.raises(RuntimeError, match="without an open partial"): writer.close_partial_movie_stream() def test_open_partial_movie_stream_without_path_raises(config, tmp_path): - from manim.scene.scene_file_writer import SceneFileWriter - with tempconfig({"media_dir": str(tmp_path)}): - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "GuardScene", _VIDEO_OUTPUT) + writer = _make_writer("GuardScene") writer.partial_movie_files = [None] with pytest.raises(RuntimeError, match="partial movie file path"): @@ -1092,12 +1067,8 @@ def test_write_frame_without_open_stream_drops_frame(config, tmp_path): 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 - with tempconfig({"media_dir": str(tmp_path)}): - renderer = Mock() - renderer.num_plays = 0 - writer = SceneFileWriter(renderer, "DropFrameScene", _VIDEO_OUTPUT) + writer = _make_writer("DropFrameScene") assert writer._current_encode_job is None # Must not raise and must not create a job.