From d78fe9374397ba73f16c7ba21b1b1b879a73400b Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Sat, 30 Nov 2024 10:08:41 -0600 Subject: [PATCH 01/56] Remove print("Reloading...") --- manimlib/reload_manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index 3d8e4f17..585a9eba 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -37,8 +37,6 @@ class ReloadManager: except KillEmbedded: # Requested via the `exit_raise` IPython runline magic # by means of our scene.reload() command - print("Reloading...") - for scene in self.scenes: scene.tear_down() From f8280a12be5c80faffbcf2979cb2eef9c3a31fdc Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Sat, 30 Nov 2024 10:08:54 -0600 Subject: [PATCH 02/56] Change where exception mode is set, to be quieter --- manimlib/scene/scene.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 4148f1bc..8fa2c139 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -232,7 +232,11 @@ class Scene(object): # the local namespace of the caller caller_frame = inspect.currentframe().f_back module = get_module(caller_frame.f_globals["__file__"]) - shell = InteractiveShellEmbed(user_module=module) + shell = InteractiveShellEmbed( + user_module=module, + display_banner=False, + xmode=self.embed_exception_mode + ) self.shell = shell # Add a few custom shortcuts to that local namespace @@ -288,9 +292,6 @@ class Scene(object): shell.set_custom_exc((Exception,), custom_exc) - # Set desired exception mode - shell.magic(f"xmode {self.embed_exception_mode}") - # Launch shell shell() From 671a31b298ede5e51bf91f8b143813e1079873e0 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Tue, 3 Dec 2024 15:14:48 -0600 Subject: [PATCH 03/56] Add default fallback monitor for when no monitors are detected --- manimlib/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/manimlib/config.py b/manimlib/config.py index 7065056e..29be6999 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -435,7 +435,8 @@ def get_window_config(args: Namespace, custom_config: dict, camera_config: dict) try: monitors = screeninfo.get_monitors() except screeninfo.ScreenInfoError: - pass + # Default fallback + monitors = [screeninfo.Monitor(width=1920, height=1080)] mon_index = custom_config["window_monitor"] monitor = monitors[min(mon_index, len(monitors) - 1)] aspect_ratio = camera_config["pixel_width"] / camera_config["pixel_height"] From 88370d4d5da329d4866b5edd68cf7e267d0d822e Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 4 Dec 2024 19:11:21 -0600 Subject: [PATCH 04/56] Have StringMobject work with svg strings rather than necessarily writing to file Change SVGMobject to allow taking in a string of svg code as an input --- manimlib/mobject/svg/string_mobject.py | 15 ++++--- manimlib/mobject/svg/svg_mobject.py | 28 +++++++----- manimlib/mobject/svg/tex_mobject.py | 7 ++- manimlib/mobject/svg/text_mobject.py | 59 ++++++++++++++------------ 4 files changed, 63 insertions(+), 46 deletions(-) diff --git a/manimlib/mobject/svg/string_mobject.py b/manimlib/mobject/svg/string_mobject.py index 98277da5..85031d87 100644 --- a/manimlib/mobject/svg/string_mobject.py +++ b/manimlib/mobject/svg/string_mobject.py @@ -66,17 +66,18 @@ class StringMobject(SVGMobject, ABC): self.use_labelled_svg = use_labelled_svg self.parse() - super().__init__(**kwargs) + svg_string = self.get_svg_string() + super().__init__(svg_string=svg_string, **kwargs) self.set_stroke(stroke_color, stroke_width) self.set_fill(fill_color, border_width=fill_border_width) self.labels = [submob.label for submob in self.submobjects] - def get_file_path(self, is_labelled: bool = False) -> str: - is_labelled = is_labelled or self.use_labelled_svg - return self.get_file_path_by_content(self.get_content(is_labelled)) + def get_svg_string(self, is_labelled: bool = False) -> str: + content = self.get_content(is_labelled or self.use_labelled_svg) + return self.get_svg_string_by_content(content) @abstractmethod - def get_file_path_by_content(self, content: str) -> str: + def get_svg_string_by_content(self, content: str) -> str: return "" def assign_labels_by_color(self, mobjects: list[VMobject]) -> None: @@ -109,8 +110,8 @@ class StringMobject(SVGMobject, ABC): ) ) - def mobjects_from_file(self, file_path: str) -> list[VMobject]: - submobs = super().mobjects_from_file(file_path) + def mobjects_from_svg_string(self, svg_string: str) -> list[VMobject]: + submobs = super().mobjects_from_svg_string(svg_string) if self.use_labelled_svg: # This means submobjects are colored according to spans diff --git a/manimlib/mobject/svg/svg_mobject.py b/manimlib/mobject/svg/svg_mobject.py index 020ed762..688fa4c5 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -6,6 +6,7 @@ from xml.etree import ElementTree as ET import numpy as np import svgelements as se import io +from pathlib import Path from manimlib.constants import RIGHT from manimlib.logger import log @@ -43,6 +44,7 @@ class SVGMobject(VMobject): def __init__( self, file_name: str = "", + svg_string: str = "", should_center: bool = True, height: float | None = None, width: float | None = None, @@ -67,11 +69,19 @@ class SVGMobject(VMobject): path_string_config: dict = dict(), **kwargs ): - self.file_name = file_name or self.file_name + if svg_string != "": + self.svg_string = svg_string + elif file_name != "": + self.svg_string = self.file_name_to_svg_string(file_name) + elif self.file_name != "": + self.file_name_to_svg_string(self.file_name) + else: + raise Exception("Must specify either a file_name or svg_string SVGMobject") + self.svg_default = dict(svg_default) self.path_string_config = dict(path_string_config) - super().__init__(**kwargs ) + super().__init__(**kwargs) self.init_svg_mobject() self.ensure_positive_orientation() @@ -101,7 +111,7 @@ class SVGMobject(VMobject): if hash_val in SVG_HASH_TO_MOB_MAP: submobs = [sm.copy() for sm in SVG_HASH_TO_MOB_MAP[hash_val]] else: - submobs = self.mobjects_from_file(self.get_file_path()) + submobs = self.mobjects_from_svg_string(self.svg_string) SVG_HASH_TO_MOB_MAP[hash_val] = [sm.copy() for sm in submobs] self.add(*submobs) @@ -115,11 +125,11 @@ class SVGMobject(VMobject): self.__class__.__name__, self.svg_default, self.path_string_config, - self.file_name + self.svg_string ) - def mobjects_from_file(self, file_path: str) -> list[VMobject]: - element_tree = ET.parse(file_path) + def mobjects_from_svg_string(self, svg_string: str) -> list[VMobject]: + element_tree = ET.ElementTree(ET.fromstring(svg_string)) new_tree = self.modify_xml_tree(element_tree) # New svg based on tree contents @@ -131,10 +141,8 @@ class SVGMobject(VMobject): return self.mobjects_from_svg(svg) - def get_file_path(self) -> str: - if self.file_name is None: - raise Exception("Must specify file for SVGMobject") - return get_full_vector_image_path(self.file_name) + def file_name_to_svg_string(self, file_name: str) -> str: + return Path(get_full_vector_image_path(file_name)).read_text() def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree: config_style_attrs = self.generate_config_style_dict() diff --git a/manimlib/mobject/svg/tex_mobject.py b/manimlib/mobject/svg/tex_mobject.py index 5211cbcb..9412830f 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from pathlib import Path from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.mobject.types.vectorized_mobject import VGroup @@ -82,10 +83,12 @@ class Tex(StringMobject): self.additional_preamble ) - def get_file_path_by_content(self, content: str) -> str: - return tex_content_to_svg_file( + def get_svg_string_by_content(self, content: str) -> str: + # TODO, implement this without writing to a file + file_path = tex_content_to_svg_file( content, self.template, self.additional_preamble, self.tex_string ) + return Path(file_path).read_text() def _handle_scale_side_effects(self, scale_factor: float) -> Self: self.font_size *= scale_factor diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index a16d322f..6989515e 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -4,6 +4,8 @@ from contextlib import contextmanager import os from pathlib import Path import re +import tempfile +import hashlib import manimpango import pygments @@ -169,7 +171,8 @@ class MarkupText(StringMobject): self.disable_ligatures ) - def get_file_path_by_content(self, content: str) -> str: + def get_svg_string_by_content(self, content: str) -> str: + # TODO, check the cache hash_content = str(( content, self.justify, @@ -177,14 +180,11 @@ class MarkupText(StringMobject): self.alignment, self.line_width )) - svg_file = os.path.join( - get_text_dir(), hash_string(hash_content) + ".svg" - ) - if not os.path.exists(svg_file): - self.markup_to_svg(content, svg_file) - return svg_file + # hash_string(hash_content) + key = hashlib.sha256(hash_content.encode()).hexdigest() + return self.markup_to_svg_string(content) - def markup_to_svg(self, markup_str: str, file_name: str) -> str: + def markup_to_svg_string(self, markup_str: str) -> str: self.validate_markup_string(markup_str) # `manimpango` is under construction, @@ -195,25 +195,30 @@ class MarkupText(StringMobject): else: pango_width = self.line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH - return manimpango.MarkupUtils.text2svg( - text=markup_str, - font="", # Already handled - slant="NORMAL", # Already handled - weight="NORMAL", # Already handled - size=1, # Already handled - _=0, # Empty parameter - disable_liga=False, - file_name=file_name, - START_X=0, - START_Y=0, - width=DEFAULT_CANVAS_WIDTH, - height=DEFAULT_CANVAS_HEIGHT, - justify=self.justify, - indent=self.indent, - line_spacing=None, # Already handled - alignment=alignment, - pango_width=pango_width - ) + with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+') as tmp: + manimpango.MarkupUtils.text2svg( + text=markup_str, + font="", # Already handled + slant="NORMAL", # Already handled + weight="NORMAL", # Already handled + size=1, # Already handled + _=0, # Empty parameter + disable_liga=False, + file_name=tmp.name, + START_X=0, + START_Y=0, + width=DEFAULT_CANVAS_WIDTH, + height=DEFAULT_CANVAS_HEIGHT, + justify=self.justify, + indent=self.indent, + line_spacing=None, # Already handled + alignment=alignment, + pango_width=pango_width + ) + + # Read the contents + tmp.seek(0) + return tmp.read() @staticmethod def validate_markup_string(markup_str: str) -> None: From 129e512b0c1258de621eba14e01594dfa1c5847b Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 4 Dec 2024 19:51:01 -0600 Subject: [PATCH 05/56] Add caching functionality, and have Tex and Text both use it for saved svg strings --- manimlib/__init__.py | 1 + manimlib/mobject/svg/old_tex_mobject.py | 12 ++++---- manimlib/mobject/svg/svg_mobject.py | 1 - manimlib/mobject/svg/tex_mobject.py | 12 ++++---- manimlib/mobject/svg/text_mobject.py | 12 ++++---- manimlib/utils/cache.py | 33 +++++++++++++++++++++ manimlib/utils/tex_file_writing.py | 39 ++++++++----------------- 7 files changed, 64 insertions(+), 46 deletions(-) create mode 100644 manimlib/utils/cache.py diff --git a/manimlib/__init__.py b/manimlib/__init__.py index 8467f980..c3696701 100644 --- a/manimlib/__init__.py +++ b/manimlib/__init__.py @@ -61,6 +61,7 @@ from manimlib.scene.interactive_scene import * from manimlib.scene.scene import * from manimlib.utils.bezier import * +from manimlib.utils.cache import * from manimlib.utils.color import * from manimlib.utils.dict_ops import * from manimlib.utils.customization import * diff --git a/manimlib/mobject/svg/old_tex_mobject.py b/manimlib/mobject/svg/old_tex_mobject.py index 61d4dd62..343b43df 100644 --- a/manimlib/mobject/svg/old_tex_mobject.py +++ b/manimlib/mobject/svg/old_tex_mobject.py @@ -7,7 +7,7 @@ import re from manimlib.constants import BLACK, WHITE from manimlib.mobject.svg.svg_mobject import SVGMobject from manimlib.mobject.types.vectorized_mobject import VGroup -from manimlib.utils.tex_file_writing import tex_content_to_svg_file +from manimlib.utils.tex_file_writing import tex_to_svg from typing import TYPE_CHECKING @@ -76,12 +76,12 @@ class SingleStringTex(SVGMobject): self.additional_preamble ) - def get_file_path(self) -> str: - content = self.get_tex_file_body(self.tex_string) - file_path = tex_content_to_svg_file( - content, self.template, self.additional_preamble, self.tex_string + def get_svg_string_by_content(self, content: str) -> str: + return get_cached_value( + key=hash_string(str((content, self.template, self.additional_preamble))), + value_func=lambda: tex_to_svg(content, self.template, self.additional_preamble), + message=f"Writing {self.tex_string}..." ) - return file_path def get_tex_file_body(self, tex_string: str) -> str: new_tex = self.get_modified_expression(tex_string) diff --git a/manimlib/mobject/svg/svg_mobject.py b/manimlib/mobject/svg/svg_mobject.py index 688fa4c5..a2514316 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -20,7 +20,6 @@ from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.utils.directories import get_mobject_data_dir from manimlib.utils.images import get_full_vector_image_path from manimlib.utils.iterables import hash_obj -from manimlib.utils.simple_functions import hash_string from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/manimlib/mobject/svg/tex_mobject.py b/manimlib/mobject/svg/tex_mobject.py index 9412830f..f8078bcd 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -6,10 +6,12 @@ from pathlib import Path from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VMobject +from manimlib.utils.cache import get_cached_value from manimlib.utils.color import color_to_hex from manimlib.utils.color import hex_to_int -from manimlib.utils.tex_file_writing import tex_content_to_svg_file +from manimlib.utils.tex_file_writing import tex_to_svg from manimlib.utils.tex import num_tex_symbols +from manimlib.utils.simple_functions import hash_string from manimlib.logger import log from typing import TYPE_CHECKING @@ -84,11 +86,11 @@ class Tex(StringMobject): ) def get_svg_string_by_content(self, content: str) -> str: - # TODO, implement this without writing to a file - file_path = tex_content_to_svg_file( - content, self.template, self.additional_preamble, self.tex_string + return get_cached_value( + key=hash_string(str((content, self.template, self.additional_preamble))), + value_func=lambda: tex_to_svg(content, self.template, self.additional_preamble), + message=f"Writing {self.tex_string}..." ) - return Path(file_path).read_text() def _handle_scale_side_effects(self, scale_factor: float) -> Self: self.font_size *= scale_factor diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 6989515e..5568295a 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -16,9 +16,10 @@ from manimlib.constants import DEFAULT_PIXEL_WIDTH, FRAME_WIDTH from manimlib.constants import NORMAL from manimlib.logger import log from manimlib.mobject.svg.string_mobject import StringMobject -from manimlib.utils.customization import get_customization +from manimlib.utils.cache import get_cached_value from manimlib.utils.color import color_to_hex from manimlib.utils.color import int_to_hex +from manimlib.utils.customization import get_customization from manimlib.utils.directories import get_downloads_dir from manimlib.utils.directories import get_text_dir from manimlib.utils.simple_functions import hash_string @@ -172,17 +173,14 @@ class MarkupText(StringMobject): ) def get_svg_string_by_content(self, content: str) -> str: - # TODO, check the cache - hash_content = str(( + key = hash_string(str(( content, self.justify, self.indent, self.alignment, self.line_width - )) - # hash_string(hash_content) - key = hashlib.sha256(hash_content.encode()).hexdigest() - return self.markup_to_svg_string(content) + ))) + return get_cached_value(key, lambda: self.markup_to_svg_string(content)) def markup_to_svg_string(self, markup_str: str) -> str: self.validate_markup_string(markup_str) diff --git a/manimlib/utils/cache.py b/manimlib/utils/cache.py new file mode 100644 index 00000000..0b3f3d2e --- /dev/null +++ b/manimlib/utils/cache.py @@ -0,0 +1,33 @@ +import appdirs +import os +from diskcache import Cache +from contextlib import contextmanager + + +CACHE_SIZE = 1e9 # 1 Gig + + +def get_cached_value(key, value_func, message=""): + cache_dir = appdirs.user_cache_dir("manim") + cache = Cache(cache_dir, size_limit=CACHE_SIZE) + + value = cache.get(key) + if value is None: + with display_during_execution(message): + value = value_func() + cache.set(key, value) + return value + + +@contextmanager +def display_during_execution(message: str): + # Merge into a single line + to_print = message.replace("\n", " ") + max_characters = os.get_terminal_size().columns - 1 + if len(to_print) > max_characters: + to_print = to_print[:max_characters - 3] + "..." + try: + print(to_print, end="\r") + yield + finally: + print(" " * len(to_print), end="\r") diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index 6538adc9..94ca2a95 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -1,10 +1,12 @@ from __future__ import annotations -from contextlib import contextmanager import os import re import yaml +from pathlib import Path +import tempfile + from manimlib.config import get_custom_config from manimlib.config import get_manim_dir from manimlib.logger import log @@ -51,9 +53,10 @@ def get_tex_config() -> dict[str, str]: return SAVED_TEX_CONFIG -def tex_content_to_svg_file( - content: str, template: str, additional_preamble: str, - short_tex: str +def tex_to_svg( + content: str, + template: str, + additional_preamble: str, ) -> str: tex_config = get_tex_config() if not template or template == tex_config["template"]: @@ -74,14 +77,11 @@ def tex_content_to_svg_file( "\\end{document}" )) + "\n" - svg_file = os.path.join( - get_tex_dir(), hash_string(full_tex) + ".svg" - ) - if not os.path.exists(svg_file): - # If svg doesn't exist, create it - with display_during_execution("Writing " + short_tex): - create_tex_svg(full_tex, svg_file, compiler) - return svg_file + with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+') as tmp: + create_tex_svg(full_tex, tmp.name, compiler) + # Read the contents + tmp.seek(0) + return tmp.read() def create_tex_svg(full_tex: str, svg_file: str, compiler: str) -> None: @@ -145,20 +145,5 @@ def create_tex_svg(full_tex: str, svg_file: str, compiler: str) -> None: pass -# TODO, perhaps this should live elsewhere -@contextmanager -def display_during_execution(message: str): - # Merge into a single line - to_print = message.replace("\n", " ") - max_characters = os.get_terminal_size().columns - 1 - if len(to_print) > max_characters: - to_print = to_print[:max_characters - 3] + "..." - try: - print(to_print, end="\r") - yield - finally: - print(" " * len(to_print), end="\r") - - class LatexError(Exception): pass From ac01b144e8d1916645122f40885f816aa8f7e64c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 4 Dec 2024 20:30:53 -0600 Subject: [PATCH 06/56] Clean up tex_file_writing --- manimlib/mobject/svg/old_tex_mobject.py | 4 +- manimlib/mobject/svg/tex_mobject.py | 4 +- manimlib/utils/tex_file_writing.py | 150 ++++++++++++------------ 3 files changed, 82 insertions(+), 76 deletions(-) diff --git a/manimlib/mobject/svg/old_tex_mobject.py b/manimlib/mobject/svg/old_tex_mobject.py index 343b43df..49bc671d 100644 --- a/manimlib/mobject/svg/old_tex_mobject.py +++ b/manimlib/mobject/svg/old_tex_mobject.py @@ -7,7 +7,7 @@ import re from manimlib.constants import BLACK, WHITE from manimlib.mobject.svg.svg_mobject import SVGMobject from manimlib.mobject.types.vectorized_mobject import VGroup -from manimlib.utils.tex_file_writing import tex_to_svg +from manimlib.utils.tex_file_writing import latex_to_svg from typing import TYPE_CHECKING @@ -79,7 +79,7 @@ class SingleStringTex(SVGMobject): def get_svg_string_by_content(self, content: str) -> str: return get_cached_value( key=hash_string(str((content, self.template, self.additional_preamble))), - value_func=lambda: tex_to_svg(content, self.template, self.additional_preamble), + value_func=lambda: latex_to_svg(content, self.template, self.additional_preamble), message=f"Writing {self.tex_string}..." ) diff --git a/manimlib/mobject/svg/tex_mobject.py b/manimlib/mobject/svg/tex_mobject.py index f8078bcd..3ad52642 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -9,7 +9,7 @@ from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.utils.cache import get_cached_value from manimlib.utils.color import color_to_hex from manimlib.utils.color import hex_to_int -from manimlib.utils.tex_file_writing import tex_to_svg +from manimlib.utils.tex_file_writing import latex_to_svg from manimlib.utils.tex import num_tex_symbols from manimlib.utils.simple_functions import hash_string from manimlib.logger import log @@ -88,7 +88,7 @@ class Tex(StringMobject): def get_svg_string_by_content(self, content: str) -> str: return get_cached_value( key=hash_string(str((content, self.template, self.additional_preamble))), - value_func=lambda: tex_to_svg(content, self.template, self.additional_preamble), + value_func=lambda: latex_to_svg(content, self.template, self.additional_preamble), message=f"Writing {self.tex_string}..." ) diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index 94ca2a95..9cbd8156 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import re import yaml +import subprocess from pathlib import Path import tempfile @@ -19,9 +20,8 @@ SAVED_TEX_CONFIG = {} def get_tex_template_config(template_name: str) -> dict[str, str]: name = template_name.replace(" ", "_").lower() - with open(os.path.join( - get_manim_dir(), "manimlib", "tex_templates.yml" - ), encoding="utf-8") as tex_templates_file: + template_path = os.path.join(get_manim_dir(), "manimlib", "tex_templates.yml") + with open(template_path, encoding="utf-8") as tex_templates_file: templates_dict = yaml.safe_load(tex_templates_file) if name not in templates_dict: log.warning( @@ -53,23 +53,8 @@ def get_tex_config() -> dict[str, str]: return SAVED_TEX_CONFIG -def tex_to_svg( - content: str, - template: str, - additional_preamble: str, -) -> str: - tex_config = get_tex_config() - if not template or template == tex_config["template"]: - compiler = tex_config["compiler"] - preamble = tex_config["preamble"] - else: - config = get_tex_template_config(template) - compiler = config["compiler"] - preamble = config["preamble"] - - if additional_preamble: - preamble += "\n" + additional_preamble - full_tex = "\n\n".join(( +def get_full_tex(content: str, preamble: str = ""): + return "\n\n".join(( "\\documentclass[preview]{standalone}", preamble, "\\begin{document}", @@ -77,14 +62,32 @@ def tex_to_svg( "\\end{document}" )) + "\n" - with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+') as tmp: - create_tex_svg(full_tex, tmp.name, compiler) - # Read the contents - tmp.seek(0) - return tmp.read() +def latex_to_svg( + latex: str, + template: str = "", + additional_preamble: str = "" +) -> str: + """Convert LaTeX string to SVG string. + + Args: + latex: LaTeX source code + template: Path to a template LaTeX file + additional_preamble: String including any added "\\usepackage{...}" style imports + + Returns: + str: SVG source code + + Raises: + LatexError: If LaTeX compilation fails + NotImplementedError: If compiler is not supported + """ + tex_config = get_tex_config() + if template and template != tex_config["template"]: + tex_config = get_tex_template_config(template) + + compiler = tex_config["compiler"] -def create_tex_svg(full_tex: str, svg_file: str, compiler: str) -> None: if compiler == "latex": program = "latex" dvi_ext = ".dvi" @@ -92,57 +95,60 @@ def create_tex_svg(full_tex: str, svg_file: str, compiler: str) -> None: program = "xelatex -no-pdf" dvi_ext = ".xdv" else: - raise NotImplementedError( - f"Compiler '{compiler}' is not implemented" + raise NotImplementedError(f"Compiler '{compiler}' is not implemented") + + preamble = tex_config["preamble"] + "\n" + additional_preamble + full_tex = get_full_tex(latex, preamble) + + # Write intermediate files to a temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + base_path = os.path.join(temp_dir, "working") + tex_path = base_path + ".tex" + dvi_path = base_path + dvi_ext + + # Write tex file + with open(tex_path, "w", encoding="utf-8") as tex_file: + tex_file.write(full_tex) + + # Run latex compiler + process = subprocess.run( + [ + program.split()[0], # Split for xelatex case + "-interaction=batchmode", + "-halt-on-error", + "-output-directory=" + temp_dir, + tex_path + ] + (["--no-pdf"] if compiler == "xelatex" else []), + capture_output=True, + text=True ) - # Write tex file - root, _ = os.path.splitext(svg_file) - with open(root + ".tex", "w", encoding="utf-8") as tex_file: - tex_file.write(full_tex) + if process.returncode != 0: + # Handle error + error_str = "" + log_path = base_path + ".log" + if os.path.exists(log_path): + with open(log_path, "r", encoding="utf-8") as log_file: + content = log_file.read() + error_match = re.search(r"(?<=\n! ).*\n.*\n", content) + if error_match: + error_str = error_match.group() + raise LatexError(error_str or "LaTeX compilation failed") - # tex to dvi - if os.system(" ".join(( - program, - "-interaction=batchmode", - "-halt-on-error", - f"-output-directory=\"{os.path.dirname(svg_file)}\"", - f"\"{root}.tex\"", - ">", - os.devnull - ))): - log.error( - "LaTeX Error! Not a worry, it happens to the best of us." + # Run dvisvgm and capture output directly + process = subprocess.run( + [ + "dvisvgm", + dvi_path, + "-n", # no fonts + "-v", "0", # quiet + "--stdout", # output to stdout instead of file + ], + capture_output=True ) - error_str = "" - with open(root + ".log", "r", encoding="utf-8") as log_file: - error_match_obj = re.search(r"(?<=\n! ).*\n.*\n", log_file.read()) - if error_match_obj: - error_str = error_match_obj.group() - log.debug( - f"The error could be:\n`{error_str}`", - ) - raise LatexError(error_str) - # dvi to svg - os.system(" ".join(( - "dvisvgm", - f"\"{root}{dvi_ext}\"", - "-n", - "-v", - "0", - "-o", - f"\"{svg_file}\"", - ">", - os.devnull - ))) - - # Cleanup superfluous documents - for ext in (".tex", dvi_ext, ".log", ".aux"): - try: - os.remove(root + ext) - except FileNotFoundError: - pass + # Return SVG string + return process.stdout.decode('utf-8') class LatexError(Exception): From 0c385e820fe6fedec627626cf4a735fd5d03599a Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 4 Dec 2024 20:33:43 -0600 Subject: [PATCH 07/56] Get rid of get_tex_dir and get_text_dir --- manimlib/mobject/svg/text_mobject.py | 1 - manimlib/utils/directories.py | 8 -------- manimlib/utils/tex_file_writing.py | 1 - 3 files changed, 10 deletions(-) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 5568295a..5aaeb6a9 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -21,7 +21,6 @@ from manimlib.utils.color import color_to_hex from manimlib.utils.color import int_to_hex from manimlib.utils.customization import get_customization from manimlib.utils.directories import get_downloads_dir -from manimlib.utils.directories import get_text_dir from manimlib.utils.simple_functions import hash_string from typing import TYPE_CHECKING diff --git a/manimlib/utils/directories.py b/manimlib/utils/directories.py index daf1714c..9bccc1be 100644 --- a/manimlib/utils/directories.py +++ b/manimlib/utils/directories.py @@ -14,14 +14,6 @@ def get_temp_dir() -> str: return get_directories()["temporary_storage"] -def get_tex_dir() -> str: - return guarantee_existence(os.path.join(get_temp_dir(), "Tex")) - - -def get_text_dir() -> str: - return guarantee_existence(os.path.join(get_temp_dir(), "Text")) - - def get_mobject_data_dir() -> str: return guarantee_existence(os.path.join(get_temp_dir(), "mobject_data")) diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index 9cbd8156..b3b14725 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -11,7 +11,6 @@ import tempfile from manimlib.config import get_custom_config from manimlib.config import get_manim_dir from manimlib.logger import log -from manimlib.utils.directories import get_tex_dir from manimlib.utils.simple_functions import hash_string From 89ddfadf6b2d21cc76b334c9fecebdc9fb190353 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Wed, 4 Dec 2024 20:50:42 -0600 Subject: [PATCH 08/56] Allow for a configurable cache location --- manimlib/default_config.yml | 10 +++++----- manimlib/utils/cache.py | 6 +++--- manimlib/utils/customization.py | 4 ++++ manimlib/utils/directories.py | 4 ++++ 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 637175fb..211ec110 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -10,12 +10,12 @@ directories: vector_images: "" # If you want to use sounds, manim will look here to find it. sounds: "" - # Manim often generates tex_files or other kinds of serialized data - # to keep from having to generate the same thing too many times. By - # default, these will be stored at tempfile.gettempdir(), e.g. this might - # return whatever is at to the TMPDIR environment variable. If you want to - # specify them elsewhere, temporary_storage: "" + # For certain object types, especially Tex and Text, manim will save information + # to file to prevent the need to re-compute, e.g. recompiling the latex. By default, + # it stores this saved data to whatever directory appdirs.user_cache_dir("manim") returns, + # but here a user can specify a different cache location + cache: "" universal_import_line: "from manimlib import *" style: tex_template: "default" diff --git a/manimlib/utils/cache.py b/manimlib/utils/cache.py index 0b3f3d2e..2c1ce860 100644 --- a/manimlib/utils/cache.py +++ b/manimlib/utils/cache.py @@ -1,15 +1,15 @@ -import appdirs import os from diskcache import Cache from contextlib import contextmanager +from manimlib.utils.directories import get_cache_dir + CACHE_SIZE = 1e9 # 1 Gig def get_cached_value(key, value_func, message=""): - cache_dir = appdirs.user_cache_dir("manim") - cache = Cache(cache_dir, size_limit=CACHE_SIZE) + cache = Cache(get_cache_dir(), size_limit=CACHE_SIZE) value = cache.get(key) if value is None: diff --git a/manimlib/utils/customization.py b/manimlib/utils/customization.py index 94923b43..7426deb6 100644 --- a/manimlib/utils/customization.py +++ b/manimlib/utils/customization.py @@ -1,5 +1,6 @@ import os import tempfile +import appdirs from manimlib.config import get_custom_config from manimlib.config import get_manim_dir @@ -17,6 +18,9 @@ def get_customization(): if not directories["temporary_storage"]: directories["temporary_storage"] = tempfile.gettempdir() + if not directories["cache"]: + directories["cache"] = appdirs.user_cache_dir("manim") + # Assumes all shaders are written into manimlib/shaders directories["shaders"] = os.path.join( get_manim_dir(), "manimlib", "shaders" diff --git a/manimlib/utils/directories.py b/manimlib/utils/directories.py index 9bccc1be..8ef3c850 100644 --- a/manimlib/utils/directories.py +++ b/manimlib/utils/directories.py @@ -10,6 +10,10 @@ def get_directories() -> dict[str, str]: return get_customization()["directories"] +def get_cache_dir() -> str: + return get_directories()["cache"] + + def get_temp_dir() -> str: return get_directories()["temporary_storage"] From 43821ab2baafe44e5b0de99ed8e4d97cc44e2d20 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 10:09:15 -0600 Subject: [PATCH 09/56] Make caching on disk a decorator, and update implementations for Tex and Text mobjects --- manimlib/mobject/svg/old_tex_mobject.py | 6 +- manimlib/mobject/svg/tex_mobject.py | 7 +- manimlib/mobject/svg/text_mobject.py | 111 +++++++++++++----------- manimlib/utils/cache.py | 32 +++++-- manimlib/utils/customization.py | 2 + manimlib/utils/tex_file_writing.py | 20 ++++- 6 files changed, 104 insertions(+), 74 deletions(-) diff --git a/manimlib/mobject/svg/old_tex_mobject.py b/manimlib/mobject/svg/old_tex_mobject.py index 49bc671d..7adc216e 100644 --- a/manimlib/mobject/svg/old_tex_mobject.py +++ b/manimlib/mobject/svg/old_tex_mobject.py @@ -77,11 +77,7 @@ class SingleStringTex(SVGMobject): ) def get_svg_string_by_content(self, content: str) -> str: - return get_cached_value( - key=hash_string(str((content, self.template, self.additional_preamble))), - value_func=lambda: latex_to_svg(content, self.template, self.additional_preamble), - message=f"Writing {self.tex_string}..." - ) + return latex_to_svg(content, self.template, self.additional_preamble) def get_tex_file_body(self, tex_string: str) -> str: new_tex = self.get_modified_expression(tex_string) diff --git a/manimlib/mobject/svg/tex_mobject.py b/manimlib/mobject/svg/tex_mobject.py index 3ad52642..3e0a460a 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -6,7 +6,6 @@ from pathlib import Path from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VMobject -from manimlib.utils.cache import get_cached_value from manimlib.utils.color import color_to_hex from manimlib.utils.color import hex_to_int from manimlib.utils.tex_file_writing import latex_to_svg @@ -86,11 +85,7 @@ class Tex(StringMobject): ) def get_svg_string_by_content(self, content: str) -> str: - return get_cached_value( - key=hash_string(str((content, self.template, self.additional_preamble))), - value_func=lambda: latex_to_svg(content, self.template, self.additional_preamble), - message=f"Writing {self.tex_string}..." - ) + return latex_to_svg(content, self.template, self.additional_preamble, short_tex=self.tex_string) def _handle_scale_side_effects(self, scale_factor: float) -> Self: self.font_size *= scale_factor diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 5aaeb6a9..fd0e891b 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -16,7 +16,7 @@ from manimlib.constants import DEFAULT_PIXEL_WIDTH, FRAME_WIDTH from manimlib.constants import NORMAL from manimlib.logger import log from manimlib.mobject.svg.string_mobject import StringMobject -from manimlib.utils.cache import get_cached_value +from manimlib.utils.cache import cache_on_disk from manimlib.utils.color import color_to_hex from manimlib.utils.color import int_to_hex from manimlib.utils.customization import get_customization @@ -51,6 +51,57 @@ class _Alignment: self.value = _Alignment.VAL_DICT[s.upper()] +@cache_on_disk +def markup_to_svg_string( + markup_str: str, + justify: bool = False, + indent: float = 0, + alignment: str = "", + line_width: float | None = None, +) -> str: + validate_error = manimpango.MarkupUtils.validate(markup_str) + if validate_error: + raise ValueError( + f"Invalid markup string \"{markup_str}\"\n" + \ + f"{validate_error}" + ) + + # `manimpango` is under construction, + # so the following code is intended to suit its interface + alignment = _Alignment(alignment) + if line_width is None: + pango_width = -1 + else: + pango_width = line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH + + # Write the result to a temporary svg file, and return it's contents. + # TODO, better would be to have this not write to file at all + with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+') as tmp: + manimpango.MarkupUtils.text2svg( + text=markup_str, + font="", # Already handled + slant="NORMAL", # Already handled + weight="NORMAL", # Already handled + size=1, # Already handled + _=0, # Empty parameter + disable_liga=False, + file_name=tmp.name, + START_X=0, + START_Y=0, + width=DEFAULT_CANVAS_WIDTH, + height=DEFAULT_CANVAS_HEIGHT, + justify=justify, + indent=indent, + line_spacing=None, # Already handled + alignment=alignment, + pango_width=pango_width + ) + + # Read the contents + tmp.seek(0) + return tmp.read() + + class MarkupText(StringMobject): # See https://docs.gtk.org/Pango/pango_markup.html MARKUP_TAGS = { @@ -172,59 +223,13 @@ class MarkupText(StringMobject): ) def get_svg_string_by_content(self, content: str) -> str: - key = hash_string(str(( + self.content = content + return markup_to_svg_string( content, - self.justify, - self.indent, - self.alignment, - self.line_width - ))) - return get_cached_value(key, lambda: self.markup_to_svg_string(content)) - - def markup_to_svg_string(self, markup_str: str) -> str: - self.validate_markup_string(markup_str) - - # `manimpango` is under construction, - # so the following code is intended to suit its interface - alignment = _Alignment(self.alignment) - if self.line_width is None: - pango_width = -1 - else: - pango_width = self.line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH - - with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+') as tmp: - manimpango.MarkupUtils.text2svg( - text=markup_str, - font="", # Already handled - slant="NORMAL", # Already handled - weight="NORMAL", # Already handled - size=1, # Already handled - _=0, # Empty parameter - disable_liga=False, - file_name=tmp.name, - START_X=0, - START_Y=0, - width=DEFAULT_CANVAS_WIDTH, - height=DEFAULT_CANVAS_HEIGHT, - justify=self.justify, - indent=self.indent, - line_spacing=None, # Already handled - alignment=alignment, - pango_width=pango_width - ) - - # Read the contents - tmp.seek(0) - return tmp.read() - - @staticmethod - def validate_markup_string(markup_str: str) -> None: - validate_error = manimpango.MarkupUtils.validate(markup_str) - if not validate_error: - return - raise ValueError( - f"Invalid markup string \"{markup_str}\"\n" + \ - f"{validate_error}" + justify=self.justify, + indent=self.indent, + alignment=self.alignment, + line_width=self.line_width ) # Toolkits diff --git a/manimlib/utils/cache.py b/manimlib/utils/cache.py index 2c1ce860..58ff7ca1 100644 --- a/manimlib/utils/cache.py +++ b/manimlib/utils/cache.py @@ -1,22 +1,38 @@ +from __future__ import annotations + import os from diskcache import Cache from contextlib import contextmanager +from functools import wraps from manimlib.utils.directories import get_cache_dir +from manimlib.utils.simple_functions import hash_string + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + T = TypeVar('T') CACHE_SIZE = 1e9 # 1 Gig +_cache = Cache(get_cache_dir(), size_limit=CACHE_SIZE) -def get_cached_value(key, value_func, message=""): - cache = Cache(get_cache_dir(), size_limit=CACHE_SIZE) +def cache_on_disk(func: Callable[..., T]) -> Callable[..., T]: + @wraps(func) + def wrapper(*args, **kwargs): + key = hash_string("".join(map(str, [func.__name__, args, kwargs]))) + value = _cache.get(key) + if value is None: + # print(f"Executing {func.__name__}({args[0]}, ...)") + value = func(*args, **kwargs) + _cache.set(key, value) + return value + return wrapper - value = cache.get(key) - if value is None: - with display_during_execution(message): - value = value_func() - cache.set(key, value) - return value + +def clear_cache(): + _cache.clear() @contextmanager diff --git a/manimlib/utils/customization.py b/manimlib/utils/customization.py index 7426deb6..a011ec07 100644 --- a/manimlib/utils/customization.py +++ b/manimlib/utils/customization.py @@ -11,7 +11,9 @@ CUSTOMIZATION = {} def get_customization(): if not CUSTOMIZATION: + print(CUSTOMIZATION) CUSTOMIZATION.update(get_custom_config()) + print(CUSTOMIZATION) directories = CUSTOMIZATION["directories"] # Unless user has specified otherwise, use the system default temp # directory for storing tex files, mobject_data, etc. diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index b3b14725..d5208c02 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -8,6 +8,7 @@ import subprocess from pathlib import Path import tempfile +from manimlib.utils.cache import cache_on_disk from manimlib.config import get_custom_config from manimlib.config import get_manim_dir from manimlib.logger import log @@ -62,10 +63,13 @@ def get_full_tex(content: str, preamble: str = ""): )) + "\n" +@cache_on_disk def latex_to_svg( latex: str, template: str = "", - additional_preamble: str = "" + additional_preamble: str = "", + short_tex: str = "", + show_message_during_execution: bool = True, ) -> str: """Convert LaTeX string to SVG string. @@ -81,6 +85,13 @@ def latex_to_svg( LatexError: If LaTeX compilation fails NotImplementedError: If compiler is not supported """ + if show_message_during_execution: + max_message_len = 80 + message = f"Writing {short_tex or latex}" + if len(message) > max_message_len: + message = message[:max_message_len - 3] + "..." + print(message, end="\r") + tex_config = get_tex_config() if template and template != tex_config["template"]: tex_config = get_tex_template_config(template) @@ -147,7 +158,12 @@ def latex_to_svg( ) # Return SVG string - return process.stdout.decode('utf-8') + result = process.stdout.decode('utf-8') + + if show_message_during_execution: + print(" " * len(message), end="\r") + + return result class LatexError(Exception): From cfb7d2fa47f13799a19088d880fb6ac06b95f970 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 10:09:48 -0600 Subject: [PATCH 10/56] Remove stray prints --- manimlib/utils/customization.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/manimlib/utils/customization.py b/manimlib/utils/customization.py index a011ec07..7426deb6 100644 --- a/manimlib/utils/customization.py +++ b/manimlib/utils/customization.py @@ -11,9 +11,7 @@ CUSTOMIZATION = {} def get_customization(): if not CUSTOMIZATION: - print(CUSTOMIZATION) CUSTOMIZATION.update(get_custom_config()) - print(CUSTOMIZATION) directories = CUSTOMIZATION["directories"] # Unless user has specified otherwise, use the system default temp # directory for storing tex files, mobject_data, etc. From 34ad61d01397415924ef2790a32f29d420695d21 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 11:53:18 -0600 Subject: [PATCH 11/56] Clean up how configuration is handled In principle, all we need here is that manim looks to the default_config.yaml file, and updates it based on any local configuration files, whether in the current working directory or as specified by a CLI argument. --- manimlib/config.py | 100 ++++++++--------------------- manimlib/extract_scene.py | 4 +- manimlib/utils/customization.py | 27 +------- manimlib/utils/directories.py | 10 ++- manimlib/utils/tex_file_writing.py | 4 +- 5 files changed, 41 insertions(+), 104 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 29be6999..013d6180 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -10,6 +10,8 @@ import screeninfo import sys import yaml +from functools import lru_cache + from manimlib.logger import log from manimlib.utils.dict_ops import merge_dicts_recursively from manimlib.utils.init_config import init_customization @@ -19,9 +21,6 @@ if TYPE_CHECKING: Module = importlib.util.types.ModuleType -__config_file__ = "custom_config.yml" - - def parse_cli(): try: parser = argparse.ArgumentParser() @@ -300,69 +299,27 @@ def get_scene_module(args: Namespace) -> Module: ) -def get_custom_config(): - global __config_file__ +def load_yaml(file_path: str): + try: + with open(file_path, "r") as file: + return yaml.safe_load(file) or {} + except FileNotFoundError: + return {} + +@lru_cache +def get_global_config(): + args = parse_cli() global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml") - if os.path.exists(global_defaults_file): - with open(global_defaults_file, "r") as file: - custom_config = yaml.safe_load(file) + print(f"global_defaults_file = {global_defaults_file}") + print(f"args.config_file = {args.config_file}") - if os.path.exists(__config_file__): - with open(__config_file__, "r") as file: - local_defaults = yaml.safe_load(file) - if local_defaults: - custom_config = merge_dicts_recursively( - custom_config, - local_defaults, - ) - else: - with open(__config_file__, "r") as file: - custom_config = yaml.safe_load(file) - - # Check temporary storage(custom_config) - if custom_config["directories"]["temporary_storage"] == "" and sys.platform == "win32": - log.warning( - "You may be using Windows platform and have not specified the path of" + \ - " `temporary_storage`, which may cause OSError. So it is recommended" + \ - " to specify the `temporary_storage` in the config file (.yml)" - ) - - return custom_config - - -def init_global_config(config_file): - global __config_file__ - - # ensure __config_file__ always exists - if config_file is not None: - if not os.path.exists(config_file): - log.error(f"Can't find {config_file}.") - if sys.platform == 'win32': - log.info(f"Copying default configuration file to {config_file}...") - os.system(f"copy default_config.yml {config_file}") - elif sys.platform in ["linux2", "darwin"]: - log.info(f"Copying default configuration file to {config_file}...") - os.system(f"cp default_config.yml {config_file}") - else: - log.info("Please create the configuration file manually.") - log.info("Read configuration from default_config.yml.") - else: - __config_file__ = config_file - - global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml") - - if not (os.path.exists(global_defaults_file) or os.path.exists(__config_file__)): - log.info("There is no configuration file detected. Switch to the config file initializer:") - init_customization() - - elif not os.path.exists(__config_file__): - log.info(f"Using the default configuration file, which you can modify in `{global_defaults_file}`") - log.info( - "If you want to create a local configuration file, you can create a file named" + \ - f" `{__config_file__}`, or run `manimgl --config`" - ) + return merge_dicts_recursively( + load_yaml(global_defaults_file), + load_yaml("custom_config.yml"), # From current working directory + load_yaml(args.config_file) if args.config_file else {}, + ) def get_file_ext(args: Namespace) -> str: @@ -498,16 +455,15 @@ def get_camera_config(args: Namespace, custom_config: dict) -> dict: def get_configuration(args: Namespace) -> dict: - init_global_config(args.config_file) - custom_config = get_custom_config() - camera_config = get_camera_config(args, custom_config) - window_config = get_window_config(args, custom_config, camera_config) + global_config = get_global_config() + camera_config = get_camera_config(args, global_config) + window_config = get_window_config(args, global_config, camera_config) start, end = get_animations_numbers(args) return { "module": get_scene_module(args), "scene_names": args.scene_names, - "file_writer_config": get_file_writer_config(args, custom_config), + "file_writer_config": get_file_writer_config(args, global_config), "camera_config": camera_config, "window_config": window_config, "quiet": args.quiet or args.write_all, @@ -520,8 +476,8 @@ def get_configuration(args: Namespace) -> dict: "leave_progress_bars": args.leave_progress_bars, "show_animation_progress": args.show_animation_progress, "prerun": args.prerun, - "embed_exception_mode": custom_config["embed_exception_mode"], - "embed_error_sound": custom_config["embed_error_sound"], + "embed_exception_mode": global_config["embed_exception_mode"], + "embed_error_sound": global_config["embed_error_sound"], } @@ -530,15 +486,15 @@ def get_frame_height(): def get_aspect_ratio(): - cam_config = get_camera_config(parse_cli(), get_custom_config()) + cam_config = get_camera_config(parse_cli(), get_global_config()) return cam_config['pixel_width'] / cam_config['pixel_height'] def get_default_pixel_width(): - cam_config = get_camera_config(parse_cli(), get_custom_config()) + cam_config = get_camera_config(parse_cli(), get_global_config()) return cam_config['pixel_width'] def get_default_pixel_height(): - cam_config = get_camera_config(parse_cli(), get_custom_config()) + cam_config = get_camera_config(parse_cli(), get_global_config()) return cam_config['pixel_height'] diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 101c5725..969a51c8 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -2,7 +2,7 @@ import copy import inspect import sys -from manimlib.config import get_custom_config +from manimlib.config import get_global_config from manimlib.logger import log from manimlib.scene.interactive_scene import InteractiveScene from manimlib.scene.scene import Scene @@ -10,7 +10,7 @@ from manimlib.scene.scene import Scene class BlankScene(InteractiveScene): def construct(self): - exec(get_custom_config()["universal_import_line"]) + exec(get_global_config()["universal_import_line"]) self.embed() diff --git a/manimlib/utils/customization.py b/manimlib/utils/customization.py index 7426deb6..15431bfb 100644 --- a/manimlib/utils/customization.py +++ b/manimlib/utils/customization.py @@ -1,28 +1,5 @@ -import os -import tempfile -import appdirs - -from manimlib.config import get_custom_config -from manimlib.config import get_manim_dir - - -CUSTOMIZATION = {} +from manimlib.config import get_global_config def get_customization(): - if not CUSTOMIZATION: - CUSTOMIZATION.update(get_custom_config()) - directories = CUSTOMIZATION["directories"] - # Unless user has specified otherwise, use the system default temp - # directory for storing tex files, mobject_data, etc. - if not directories["temporary_storage"]: - directories["temporary_storage"] = tempfile.gettempdir() - - if not directories["cache"]: - directories["cache"] = appdirs.user_cache_dir("manim") - - # Assumes all shaders are written into manimlib/shaders - directories["shaders"] = os.path.join( - get_manim_dir(), "manimlib", "shaders" - ) - return CUSTOMIZATION + return get_global_config() diff --git a/manimlib/utils/directories.py b/manimlib/utils/directories.py index 8ef3c850..57adcf6f 100644 --- a/manimlib/utils/directories.py +++ b/manimlib/utils/directories.py @@ -1,7 +1,11 @@ from __future__ import annotations import os +import tempfile +import appdirs + +from manimlib.config import get_manim_dir from manimlib.utils.customization import get_customization from manimlib.utils.file_ops import guarantee_existence @@ -11,11 +15,11 @@ def get_directories() -> dict[str, str]: def get_cache_dir() -> str: - return get_directories()["cache"] + return get_directories()["cache"] or appdirs.user_cache_dir("manim") def get_temp_dir() -> str: - return get_directories()["temporary_storage"] + return get_directories()["temporary_storage"] or tempfile.gettempdir() def get_mobject_data_dir() -> str: @@ -43,4 +47,4 @@ def get_sound_dir() -> str: def get_shader_dir() -> str: - return get_directories()["shaders"] + return os.path.join(get_manim_dir(), "manimlib", "shaders") diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index d5208c02..88c8b70d 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -9,7 +9,7 @@ from pathlib import Path import tempfile from manimlib.utils.cache import cache_on_disk -from manimlib.config import get_custom_config +from manimlib.config import get_global_config from manimlib.config import get_manim_dir from manimlib.logger import log from manimlib.utils.simple_functions import hash_string @@ -43,7 +43,7 @@ def get_tex_config() -> dict[str, str]: """ # Only load once, then save thereafter if not SAVED_TEX_CONFIG: - template_name = get_custom_config()["style"]["tex_template"] + template_name = get_global_config()["style"]["tex_template"] template_config = get_tex_template_config(template_name) SAVED_TEX_CONFIG.update({ "template": template_name, From b593cde3177cf5aedb7c50a6c87111d51626b8f6 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 11:53:55 -0600 Subject: [PATCH 12/56] Make the default size for hash_string an option --- manimlib/utils/simple_functions.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/manimlib/utils/simple_functions.py b/manimlib/utils/simple_functions.py index d4836765..43976a0b 100644 --- a/manimlib/utils/simple_functions.py +++ b/manimlib/utils/simple_functions.py @@ -96,7 +96,6 @@ def binary_search(function: Callable[[float], float], return mh -def hash_string(string: str) -> str: - # Truncating at 16 bytes for cleanliness +def hash_string(string: str, n_bytes=16) -> str: hasher = hashlib.sha256(string.encode()) - return hasher.hexdigest()[:16] + return hasher.hexdigest()[:n_bytes] From 3b9ef57b220a324ba5fcb872f0f680e86c282b10 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 11:59:01 -0600 Subject: [PATCH 13/56] Remove utils/customization.py --- manimlib/__init__.py | 1 - manimlib/mobject/shape_matchers.py | 4 ++-- manimlib/mobject/svg/text_mobject.py | 7 ++++--- manimlib/utils/customization.py | 5 ----- manimlib/utils/directories.py | 4 ++-- manimlib/window.py | 7 ++++--- 6 files changed, 12 insertions(+), 16 deletions(-) delete mode 100644 manimlib/utils/customization.py diff --git a/manimlib/__init__.py b/manimlib/__init__.py index c3696701..6df40dc8 100644 --- a/manimlib/__init__.py +++ b/manimlib/__init__.py @@ -64,7 +64,6 @@ from manimlib.utils.bezier import * from manimlib.utils.cache import * from manimlib.utils.color import * from manimlib.utils.dict_ops import * -from manimlib.utils.customization import * from manimlib.utils.debug import * from manimlib.utils.directories import * from manimlib.utils.file_ops import * diff --git a/manimlib/mobject/shape_matchers.py b/manimlib/mobject/shape_matchers.py index 72942e14..81911ba7 100644 --- a/manimlib/mobject/shape_matchers.py +++ b/manimlib/mobject/shape_matchers.py @@ -2,6 +2,7 @@ from __future__ import annotations from colour import Color +from manimlib.config import get_global_config from manimlib.constants import BLACK, RED, YELLOW, WHITE from manimlib.constants import DL, DOWN, DR, LEFT, RIGHT, UL, UR from manimlib.constants import SMALL_BUFF @@ -9,7 +10,6 @@ from manimlib.mobject.geometry import Line from manimlib.mobject.geometry import Rectangle from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VMobject -from manimlib.utils.customization import get_customization from typing import TYPE_CHECKING @@ -57,7 +57,7 @@ class BackgroundRectangle(SurroundingRectangle): **kwargs ): if color is None: - color = get_customization()['style']['background_color'] + color = get_global_config()['style']['background_color'] super().__init__( mobject, color=color, diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index fd0e891b..170a56c7 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -12,6 +12,7 @@ import pygments import pygments.formatters import pygments.lexers +from manimlib.config import get_global_config from manimlib.constants import DEFAULT_PIXEL_WIDTH, FRAME_WIDTH from manimlib.constants import NORMAL from manimlib.logger import log @@ -19,7 +20,6 @@ from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.utils.cache import cache_on_disk from manimlib.utils.color import color_to_hex from manimlib.utils.color import int_to_hex -from manimlib.utils.customization import get_customization from manimlib.utils.directories import get_downloads_dir from manimlib.utils.simple_functions import hash_string @@ -155,13 +155,14 @@ class MarkupText(StringMobject): isolate: Selector = re.compile(r"\w+", re.U), **kwargs ): + default_style = get_global_config()["style"] self.text = text self.font_size = font_size self.justify = justify self.indent = indent - self.alignment = alignment or get_customization()["style"]["text_alignment"] + self.alignment = alignment or default_style["text_alignment"] self.line_width = line_width - self.font = font or get_customization()["style"]["font"] + self.font = font or default_style["font"] self.slant = slant self.weight = weight diff --git a/manimlib/utils/customization.py b/manimlib/utils/customization.py deleted file mode 100644 index 15431bfb..00000000 --- a/manimlib/utils/customization.py +++ /dev/null @@ -1,5 +0,0 @@ -from manimlib.config import get_global_config - - -def get_customization(): - return get_global_config() diff --git a/manimlib/utils/directories.py b/manimlib/utils/directories.py index 57adcf6f..fe0e7970 100644 --- a/manimlib/utils/directories.py +++ b/manimlib/utils/directories.py @@ -5,13 +5,13 @@ import tempfile import appdirs +from manimlib.config import get_global_config from manimlib.config import get_manim_dir -from manimlib.utils.customization import get_customization from manimlib.utils.file_ops import guarantee_existence def get_directories() -> dict[str, str]: - return get_customization()["directories"] + return get_global_config()["directories"] def get_cache_dir() -> str: diff --git a/manimlib/window.py b/manimlib/window.py index c549601b..f670ac4e 100644 --- a/manimlib/window.py +++ b/manimlib/window.py @@ -8,8 +8,8 @@ from moderngl_window.timers.clock import Timer from screeninfo import get_monitors from functools import wraps +from manimlib.config import get_global_config from manimlib.constants import FRAME_SHAPE -from manimlib.utils.customization import get_customization from typing import TYPE_CHECKING @@ -83,9 +83,10 @@ class Window(PygletWindow): self.size = (w, h) def find_initial_position(self, size: tuple[int, int]) -> tuple[int, int]: - custom_position = get_customization()["window_position"] + global_config = get_global_config() + custom_position = global_config["window_position"] + mon_index = global_config["window_monitor"] monitors = get_monitors() - mon_index = get_customization()["window_monitor"] monitor = monitors[min(mon_index, len(monitors) - 1)] window_width, window_height = size # Position might be specified with a string of the form From fc32f162a0c2d185754c8610f03a28e2e2d6733c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 13:46:47 -0600 Subject: [PATCH 14/56] Remove stray prints --- manimlib/config.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 013d6180..cf5e757e 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -311,10 +311,6 @@ def load_yaml(file_path: str): def get_global_config(): args = parse_cli() global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml") - - print(f"global_defaults_file = {global_defaults_file}") - print(f"args.config_file = {args.config_file}") - return merge_dicts_recursively( load_yaml(global_defaults_file), load_yaml("custom_config.yml"), # From current working directory From 0496402c55b38d5d1080ade94fe9506f05094408 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 14:17:53 -0600 Subject: [PATCH 15/56] Consolidate camera configuration This is still not optimal, but at least makes clearer the way that importing from constants.py kicks off some of the configuration code. --- manimlib/config.py | 36 ++++++++++++++---------------------- manimlib/constants.py | 19 +++++++++++-------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index cf5e757e..ed6d517f 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -19,6 +19,12 @@ from manimlib.utils.init_config import init_customization from typing import TYPE_CHECKING if TYPE_CHECKING: Module = importlib.util.types.ModuleType + from typing import Optional + + +# This has to be here instead of in constants.py +# due to its use in creating the camera configuration +FRAME_HEIGHT: float = 8.0 def parse_cli(): @@ -400,8 +406,13 @@ def get_window_config(args: Namespace, custom_config: dict, camera_config: dict) return dict(size=(window_width, window_height)) -def get_camera_config(args: Namespace, custom_config: dict) -> dict: - camera_config = {} +def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[dict] = None) -> dict: + if args is None: + args = parse_cli() + if custom_config is None: + custom_config = get_global_config() + + camera_config = dict() camera_resolutions = custom_config["camera_resolutions"] if args.resolution: resolution = args.resolution @@ -429,7 +440,7 @@ def get_camera_config(args: Namespace, custom_config: dict) -> dict: "pixel_width": width, "pixel_height": height, "frame_config": { - "frame_shape": ((width / height) * get_frame_height(), get_frame_height()), + "frame_shape": ((width / height) * FRAME_HEIGHT, FRAME_HEIGHT), }, "fps": fps, }) @@ -475,22 +486,3 @@ def get_configuration(args: Namespace) -> dict: "embed_exception_mode": global_config["embed_exception_mode"], "embed_error_sound": global_config["embed_error_sound"], } - - -def get_frame_height(): - return 8.0 - - -def get_aspect_ratio(): - cam_config = get_camera_config(parse_cli(), get_global_config()) - return cam_config['pixel_width'] / cam_config['pixel_height'] - - -def get_default_pixel_width(): - cam_config = get_camera_config(parse_cli(), get_global_config()) - return cam_config['pixel_width'] - - -def get_default_pixel_height(): - cam_config = get_camera_config(parse_cli(), get_global_config()) - return cam_config['pixel_height'] diff --git a/manimlib/constants.py b/manimlib/constants.py index 1eb93e08..40b6f8e8 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -1,10 +1,8 @@ from __future__ import annotations import numpy as np -from manimlib.config import get_aspect_ratio -from manimlib.config import get_default_pixel_width -from manimlib.config import get_default_pixel_height -from manimlib.config import get_frame_height +from manimlib.config import get_camera_config +from manimlib.config import FRAME_HEIGHT from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -12,16 +10,21 @@ if TYPE_CHECKING: from manimlib.typing import ManimColor, Vect3 +# TODO, it feels a bit unprincipled to have some global constants +# depend on the output of this function, all for all that configuration +# code to be run merely upon importing from this file +CAMERA_CONFIG = get_camera_config() + # Sizes relevant to default camera frame -ASPECT_RATIO: float = get_aspect_ratio() -FRAME_HEIGHT: float = get_frame_height() +ASPECT_RATIO: float = CAMERA_CONFIG['pixel_width'] / CAMERA_CONFIG['pixel_height'] +# FRAME_HEIGHT: float = 8.0 FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT) FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2 FRAME_X_RADIUS: float = FRAME_WIDTH / 2 -DEFAULT_PIXEL_HEIGHT: int = get_default_pixel_height() -DEFAULT_PIXEL_WIDTH: int = get_default_pixel_width() +DEFAULT_PIXEL_HEIGHT: int = CAMERA_CONFIG['pixel_height'] +DEFAULT_PIXEL_WIDTH: int = CAMERA_CONFIG['pixel_width'] DEFAULT_FPS: int = 30 SMALL_BUFF: float = 0.1 From 96a4a4b76f6aa9512924fd0e2f17d64d347eecf8 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 14:36:21 -0600 Subject: [PATCH 16/56] Factor out configuration to be passed into a scene vs. that used to run a scene --- manimlib/config.py | 20 ++++++++++++++------ manimlib/extract_scene.py | 27 +++++++++------------------ manimlib/shader_wrapper.py | 4 ++-- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index ed6d517f..8e4b86fe 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -461,20 +461,19 @@ def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[ return camera_config -def get_configuration(args: Namespace) -> dict: +def get_scene_config(args: Namespace) -> dict: + """ + Returns a dictionary to be used as key word arguments for Scene + """ global_config = get_global_config() camera_config = get_camera_config(args, global_config) window_config = get_window_config(args, global_config, camera_config) start, end = get_animations_numbers(args) return { - "module": get_scene_module(args), - "scene_names": args.scene_names, "file_writer_config": get_file_writer_config(args, global_config), "camera_config": camera_config, "window_config": window_config, - "quiet": args.quiet or args.write_all, - "write_all": args.write_all, "skip_animations": args.skip_animations, "start_at_animation_number": start, "end_at_animation_number": end, @@ -482,7 +481,16 @@ def get_configuration(args: Namespace) -> dict: "presenter_mode": args.presenter_mode, "leave_progress_bars": args.leave_progress_bars, "show_animation_progress": args.show_animation_progress, - "prerun": args.prerun, "embed_exception_mode": global_config["embed_exception_mode"], "embed_error_sound": global_config["embed_error_sound"], } + + +def get_run_config(args: Namespace): + return { + "module": get_scene_module(args), + "prerun": args.prerun, + "scene_names": args.scene_names, + "quiet": args.quiet or args.write_all, + "write_all": args.write_all, + } diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 969a51c8..5467d7a4 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -53,14 +53,6 @@ def prompt_user_for_choice(scene_classes): sys.exit(1) -def get_scene_config(config): - scene_parameters = inspect.signature(Scene).parameters.keys() - return { - key: config[key] - for key in set(scene_parameters).intersection(config.keys()) - } - - def compute_total_frames(scene_class, scene_config): """ When a scene is being written to file, a copy of the scene is run with @@ -79,19 +71,19 @@ def compute_total_frames(scene_class, scene_config): return int(total_time * scene_config["camera_config"]["fps"]) -def scene_from_class(scene_class, scene_config, config): +def scene_from_class(scene_class, scene_config, run_config): fw_config = scene_config["file_writer_config"] - if fw_config["write_to_movie"] and config["prerun"]: + if fw_config["write_to_movie"] and run_config["prerun"]: fw_config["total_frames"] = compute_total_frames(scene_class, scene_config) return scene_class(**scene_config) -def get_scenes_to_render(all_scene_classes, scene_config, config): - if config["write_all"]: +def get_scenes_to_render(all_scene_classes, scene_config, run_config): + if run_config["write_all"]: return [sc(**scene_config) for sc in all_scene_classes] names_to_classes = {sc.__name__ : sc for sc in all_scene_classes} - scene_names = config["scene_names"] + scene_names = run_config["scene_names"] for name in set.difference(set(scene_names), names_to_classes): log.error(f"No scene named {name} found") @@ -105,7 +97,7 @@ def get_scenes_to_render(all_scene_classes, scene_config, config): classes_to_run = prompt_user_for_choice(all_scene_classes) return [ - scene_from_class(scene_class, scene_config, config) + scene_from_class(scene_class, scene_config, run_config) for scene_class in classes_to_run ] @@ -123,13 +115,12 @@ def get_scene_classes_from_module(module): ] -def main(config): - module = config["module"] - scene_config = get_scene_config(config) +def main(scene_config, run_config): + module = run_config["module"] if module is None: # If no module was passed in, just play the blank scene return [BlankScene(**scene_config)] all_scene_classes = get_scene_classes_from_module(module) - scenes = get_scenes_to_render(all_scene_classes, scene_config, config) + scenes = get_scenes_to_render(all_scene_classes, scene_config, run_config) return scenes diff --git a/manimlib/shader_wrapper.py b/manimlib/shader_wrapper.py index da657bdd..ad16ba1f 100644 --- a/manimlib/shader_wrapper.py +++ b/manimlib/shader_wrapper.py @@ -10,7 +10,7 @@ import numpy as np from functools import lru_cache from manimlib.config import parse_cli -from manimlib.config import get_configuration +from manimlib.config import get_camera_config from manimlib.utils.iterables import resize_array from manimlib.utils.shaders import get_shader_code_from_file from manimlib.utils.shaders import get_shader_program @@ -409,7 +409,7 @@ class VShaderWrapper(ShaderWrapper): which can display that texture as a simple quad onto a screen, along with the rgb value which is meant to be discarded. """ - cam_config = get_configuration(parse_cli())['camera_config'] + cam_config = get_camera_config() size = (cam_config['pixel_width'], cam_config['pixel_height']) double_size = (2 * size[0], 2 * size[1]) From 8dfd4c1c4e7fa70a936caca24fdd5656ec1f39e6 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 14:36:43 -0600 Subject: [PATCH 17/56] Use newer extract_scene.main interface --- manimlib/reload_manager.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index 585a9eba..b073d2a8 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -59,12 +59,14 @@ class ReloadManager: self.args.embed = str(overwrite_start_at_line) # Args to Config - config = manimlib.config.get_configuration(self.args) + scene_config = manimlib.config.get_scene_config(self.args) if self.window: - config["existing_window"] = self.window # see scene initialization + scene_config["existing_window"] = self.window # see scene initialization + + run_config = manimlib.config.get_run_config(self.args) # Scenes - self.scenes = manimlib.extract_scene.main(config) + self.scenes = manimlib.extract_scene.main(scene_config, run_config) if len(self.scenes) == 0: print("No scenes found to run") return From 1d14bae092a52cf73612b33d46a4ffcf2d96abb5 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 14:37:14 -0600 Subject: [PATCH 18/56] Add clarifying message to note what exactly is being reloaded --- manimlib/reload_manager.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index b073d2a8..3d58c97a 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -78,7 +78,13 @@ class ReloadManager: break for scene in self.scenes: + if self.args.embed: + print(" ".join([ + "Loading interactive session for", + f"\033[96m{self.args.scene_names[0]}\033[0m", + f"in \033[96m{self.args.file}\033[0m", + f"at line \033[96m{self.args.embed}\033[0m" + ])) scene.run() - reload_manager = ReloadManager() From 361d9d0652c734d231cf4eef498ca1d1a7d37639 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 14:42:22 -0600 Subject: [PATCH 19/56] Minor clean up --- manimlib/constants.py | 5 ++--- manimlib/extract_scene.py | 10 ++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/manimlib/constants.py b/manimlib/constants.py index 40b6f8e8..7850796c 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -11,13 +11,12 @@ if TYPE_CHECKING: # TODO, it feels a bit unprincipled to have some global constants -# depend on the output of this function, all for all that configuration -# code to be run merely upon importing from this file +# depend on the output of this function, and for all that configuration +# code to be run merely upon importing from this file. CAMERA_CONFIG = get_camera_config() # Sizes relevant to default camera frame ASPECT_RATIO: float = CAMERA_CONFIG['pixel_width'] / CAMERA_CONFIG['pixel_height'] -# FRAME_HEIGHT: float = 8.0 FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT) FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2 diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 5467d7a4..15dd3e13 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -82,7 +82,7 @@ def get_scenes_to_render(all_scene_classes, scene_config, run_config): if run_config["write_all"]: return [sc(**scene_config) for sc in all_scene_classes] - names_to_classes = {sc.__name__ : sc for sc in all_scene_classes} + names_to_classes = {sc.__name__: sc for sc in all_scene_classes} scene_names = run_config["scene_names"] for name in set.difference(set(scene_names), names_to_classes): @@ -116,11 +116,9 @@ def get_scene_classes_from_module(module): def main(scene_config, run_config): - module = run_config["module"] - if module is None: + if run_config["module"] is None: # If no module was passed in, just play the blank scene return [BlankScene(**scene_config)] - all_scene_classes = get_scene_classes_from_module(module) - scenes = get_scenes_to_render(all_scene_classes, scene_config, run_config) - return scenes + all_scene_classes = get_scene_classes_from_module(run_config["module"]) + return get_scenes_to_render(all_scene_classes, scene_config, run_config) From e0031c63bcca5c0d7707998cefb9854bef9e8b3c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 14:55:28 -0600 Subject: [PATCH 20/56] Minor clean up --- manimlib/mobject/svg/text_mobject.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 170a56c7..9d7db418 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -52,11 +52,11 @@ class _Alignment: @cache_on_disk -def markup_to_svg_string( +def markup_to_svg( markup_str: str, justify: bool = False, indent: float = 0, - alignment: str = "", + alignment: str = "CENTER", line_width: float | None = None, ) -> str: validate_error = manimpango.MarkupUtils.validate(markup_str) @@ -225,7 +225,7 @@ class MarkupText(StringMobject): def get_svg_string_by_content(self, content: str) -> str: self.content = content - return markup_to_svg_string( + return markup_to_svg( content, justify=self.justify, indent=self.indent, From 85f84562289429ad0131329637232b20f18d75e5 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 14:56:35 -0600 Subject: [PATCH 21/56] If it's worth caching to disk, then might as well do so in memory too during development --- manimlib/mobject/svg/text_mobject.py | 2 ++ manimlib/utils/tex_file_writing.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 9d7db418..4e6e0301 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -6,6 +6,7 @@ from pathlib import Path import re import tempfile import hashlib +from functools import lru_cache import manimpango import pygments @@ -51,6 +52,7 @@ class _Alignment: self.value = _Alignment.VAL_DICT[s.upper()] +@lru_cache(maxsize=128) @cache_on_disk def markup_to_svg( markup_str: str, diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index 88c8b70d..1f2ea4fe 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -4,6 +4,7 @@ import os import re import yaml import subprocess +from functools import lru_cache from pathlib import Path import tempfile @@ -63,6 +64,7 @@ def get_full_tex(content: str, preamble: str = ""): )) + "\n" +@lru_cache(maxsize=128) @cache_on_disk def latex_to_svg( latex: str, From 4251ff436a39e69d3bed1590c84b03c3796f7c85 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 15:05:26 -0600 Subject: [PATCH 22/56] No longer any need for custom hash_seeds in Tex and Text --- manimlib/mobject/svg/tex_mobject.py | 17 ----------------- manimlib/mobject/svg/text_mobject.py | 28 ---------------------------- 2 files changed, 45 deletions(-) diff --git a/manimlib/mobject/svg/tex_mobject.py b/manimlib/mobject/svg/tex_mobject.py index 3e0a460a..5496e2df 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -67,23 +67,6 @@ class Tex(StringMobject): self.set_color_by_tex_to_color_map(self.tex_to_color_map) self.scale(SCALE_FACTOR_PER_FONT_POINT * font_size) - @property - def hash_seed(self) -> tuple: - return ( - self.__class__.__name__, - self.svg_default, - self.path_string_config, - self.base_color, - self.isolate, - self.protect, - self.tex_string, - self.alignment, - self.tex_environment, - self.tex_to_color_map, - self.template, - self.additional_preamble - ) - def get_svg_string_by_content(self, content: str) -> str: return latex_to_svg(content, self.template, self.additional_preamble, short_tex=self.tex_string) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 4e6e0301..743d68dd 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -197,34 +197,6 @@ class MarkupText(StringMobject): if height is None: self.scale(TEXT_MOB_SCALE_FACTOR) - @property - def hash_seed(self) -> tuple: - return ( - self.__class__.__name__, - self.svg_default, - self.path_string_config, - self.base_color, - self.isolate, - self.protect, - self.text, - self.font_size, - self.lsh, - self.justify, - self.indent, - self.alignment, - self.line_width, - self.font, - self.slant, - self.weight, - self.t2c, - self.t2f, - self.t2s, - self.t2w, - self.global_config, - self.local_configs, - self.disable_ligatures - ) - def get_svg_string_by_content(self, content: str) -> str: self.content = content return markup_to_svg( From 809814900671df9f3a8a7c0f31e737ead8c66dbb Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 15:05:37 -0600 Subject: [PATCH 23/56] Remove display_during_execution --- manimlib/utils/cache.py | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/manimlib/utils/cache.py b/manimlib/utils/cache.py index 58ff7ca1..e6454057 100644 --- a/manimlib/utils/cache.py +++ b/manimlib/utils/cache.py @@ -21,10 +21,9 @@ _cache = Cache(get_cache_dir(), size_limit=CACHE_SIZE) def cache_on_disk(func: Callable[..., T]) -> Callable[..., T]: @wraps(func) def wrapper(*args, **kwargs): - key = hash_string("".join(map(str, [func.__name__, args, kwargs]))) + key = hash_string(f"{func.__name__}{args}{kwargs}") value = _cache.get(key) if value is None: - # print(f"Executing {func.__name__}({args[0]}, ...)") value = func(*args, **kwargs) _cache.set(key, value) return value @@ -33,17 +32,3 @@ def cache_on_disk(func: Callable[..., T]) -> Callable[..., T]: def clear_cache(): _cache.clear() - - -@contextmanager -def display_during_execution(message: str): - # Merge into a single line - to_print = message.replace("\n", " ") - max_characters = os.get_terminal_size().columns - 1 - if len(to_print) > max_characters: - to_print = to_print[:max_characters - 3] + "..." - try: - print(to_print, end="\r") - yield - finally: - print(" " * len(to_print), end="\r") From 71e440be93f02894540fa306edfbc6753423b849 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 15:08:25 -0600 Subject: [PATCH 24/56] Get rid of (no longer used) mobject_data directory reference --- manimlib/mobject/svg/svg_mobject.py | 1 - manimlib/utils/directories.py | 4 ---- 2 files changed, 5 deletions(-) diff --git a/manimlib/mobject/svg/svg_mobject.py b/manimlib/mobject/svg/svg_mobject.py index a2514316..c668f3d4 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -17,7 +17,6 @@ from manimlib.mobject.geometry import Polyline from manimlib.mobject.geometry import Rectangle from manimlib.mobject.geometry import RoundedRectangle from manimlib.mobject.types.vectorized_mobject import VMobject -from manimlib.utils.directories import get_mobject_data_dir from manimlib.utils.images import get_full_vector_image_path from manimlib.utils.iterables import hash_obj diff --git a/manimlib/utils/directories.py b/manimlib/utils/directories.py index fe0e7970..e4a5358d 100644 --- a/manimlib/utils/directories.py +++ b/manimlib/utils/directories.py @@ -22,10 +22,6 @@ def get_temp_dir() -> str: return get_directories()["temporary_storage"] or tempfile.gettempdir() -def get_mobject_data_dir() -> str: - return guarantee_existence(os.path.join(get_temp_dir(), "mobject_data")) - - def get_downloads_dir() -> str: return guarantee_existence(os.path.join(get_temp_dir(), "manim_downloads")) From c96734ace0e64b9b1fc71f25d38ae6d8d855cca6 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 15:14:37 -0600 Subject: [PATCH 25/56] Remove get_downloads_dir reference from register_font --- manimlib/mobject/svg/text_mobject.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/manimlib/mobject/svg/text_mobject.py b/manimlib/mobject/svg/text_mobject.py index 743d68dd..3f36a7c2 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -21,7 +21,6 @@ from manimlib.mobject.svg.string_mobject import StringMobject from manimlib.utils.cache import cache_on_disk from manimlib.utils.color import color_to_hex from manimlib.utils.color import int_to_hex -from manimlib.utils.directories import get_downloads_dir from manimlib.utils.simple_functions import hash_string from typing import TYPE_CHECKING @@ -493,20 +492,10 @@ def register_font(font_file: str | Path): method with previous releases will raise an :class:`AttributeError` on macOS. """ - input_folder = Path(get_downloads_dir()).parent.resolve() - possible_paths = [ - Path(font_file), - input_folder / font_file, - ] - for path in possible_paths: - path = path.resolve() - if path.exists(): - file_path = path - break - else: - error = f"Can't find {font_file}." f"Tried these : {possible_paths}" + file_path = Path(font_file).resolve() + if not file_path.exists(): + error = f"Can't find {font_file}." raise FileNotFoundError(error) - try: assert manimpango.register_font(str(file_path)) yield From 75527563de5cf4badb8e30abd826e355e06bce01 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 15:27:57 -0600 Subject: [PATCH 26/56] Update where downloads go --- manimlib/default_config.yml | 2 +- manimlib/utils/directories.py | 2 +- manimlib/utils/file_ops.py | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 211ec110..3e38d24a 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -10,7 +10,7 @@ directories: vector_images: "" # If you want to use sounds, manim will look here to find it. sounds: "" - temporary_storage: "" + downloads: "" # For certain object types, especially Tex and Text, manim will save information # to file to prevent the need to re-compute, e.g. recompiling the latex. By default, # it stores this saved data to whatever directory appdirs.user_cache_dir("manim") returns, diff --git a/manimlib/utils/directories.py b/manimlib/utils/directories.py index e4a5358d..e4a2bc03 100644 --- a/manimlib/utils/directories.py +++ b/manimlib/utils/directories.py @@ -23,7 +23,7 @@ def get_temp_dir() -> str: def get_downloads_dir() -> str: - return guarantee_existence(os.path.join(get_temp_dir(), "manim_downloads")) + return get_directories()["downloads"] or appdirs.user_cache_dir("manim_downloads") def get_output_dir() -> str: diff --git a/manimlib/utils/file_ops.py b/manimlib/utils/file_ops.py index 0e436b68..26faa449 100644 --- a/manimlib/utils/file_ops.py +++ b/manimlib/utils/file_ops.py @@ -6,6 +6,10 @@ import hashlib import numpy as np import validators +import urllib.request + +import manimlib.utils.directories +from manimlib.utils.simple_functions import hash_string from typing import TYPE_CHECKING @@ -35,11 +39,9 @@ def find_file( # Check if this is a file online first, and if so, download # it to a temporary directory if validators.url(file_name): - import urllib.request - from manimlib.utils.directories import get_downloads_dir suffix = Path(file_name).suffix - file_hash = hashlib.sha256(file_name.encode('utf-8')).hexdigest()[:32] - folder = get_downloads_dir() + file_hash = hash_string(file_name) + folder = manimlib.utils.directories.get_downloads_dir() path = Path(folder, file_hash).with_suffix(suffix) urllib.request.urlretrieve(file_name, path) From 08acfa6f1f1478d48bdbaeed401242a472c18463 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 15:52:39 -0600 Subject: [PATCH 27/56] Easier use of subdirectories in configuration --- manimlib/config.py | 9 +++++- manimlib/default_config.yml | 25 +++++++++++----- manimlib/utils/init_config.py | 56 +++++++++++++++++++++-------------- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 8e4b86fe..3e299459 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -317,12 +317,19 @@ def load_yaml(file_path: str): def get_global_config(): args = parse_cli() global_defaults_file = os.path.join(get_manim_dir(), "manimlib", "default_config.yml") - return merge_dicts_recursively( + config = merge_dicts_recursively( load_yaml(global_defaults_file), load_yaml("custom_config.yml"), # From current working directory load_yaml(args.config_file) if args.config_file else {}, ) + # Set the subdirectories + base = config['directories']['base'] + for key, subdir in config['directories']['subdirs'].items(): + config['directories'][key] = os.path.join(base, subdir) + + return config + def get_file_ext(args: Namespace) -> str: if args.transparent: diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 3e38d24a..4c9f0c3a 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -3,14 +3,23 @@ directories: # to match the directory structure of the path to the # sourcecode generating that video mirror_module_path: False - # Where should manim output video and image files? - output: "" - # If you want to use images, manim will look to these folders to find them - raster_images: "" - vector_images: "" - # If you want to use sounds, manim will look here to find it. - sounds: "" - downloads: "" + # Manim may write to and read from teh file system, e.g. + # to render videos and to look for svg/png assets. This + # will specify where those assets live, with a base directory, + # and various subdirectory names within it + base: "" + subdirs: + # Where should manim output video and image files? + output: "videos" + # If you want to use images, manim will look to these folders to find them + raster_images: "raster_images" + vector_images: "vector_images" + # If you want to use sounds, manim will look here to find it. + sounds: "sounds" + # Place for other forms of data relevant to any projects, like csv's + data: "data" + # When downloading, say an image, where will it go? + downloads: "downloads" # For certain object types, especially Tex and Text, manim will save information # to file to prevent the need to re-compute, e.g. recompiling the latex. By default, # it stores this saved data to whatever directory appdirs.user_cache_dir("manim") returns, diff --git a/manimlib/utils/init_config.py b/manimlib/utils/init_config.py index 3d42284d..c91fd7e4 100644 --- a/manimlib/utils/init_config.py +++ b/manimlib/utils/init_config.py @@ -36,11 +36,15 @@ def init_customization() -> None: configuration = { "directories": { "mirror_module_path": False, - "output": "", - "raster_images": "", - "vector_images": "", - "sounds": "", - "temporary_storage": "", + "base": "", + "subdirs": { + "output": "videos", + "raster_images": "raster_images", + "vector_images": "vector_images", + "sounds": "sounds", + "data": "data", + "downloads": "downloads", + } }, "universal_import_line": "from manimlib import *", "style": { @@ -74,33 +78,39 @@ def init_customization() -> None: console.print("[bold]Directories:[/bold]") dir_config = configuration["directories"] - dir_config["output"] = Prompt.ask( - " Where should manim [bold]output[/bold] video and image files place [prompt.default](optional, default is none)", + dir_config["base"] = Prompt.ask( + " What base directory should manim use for reading/writing video and images? [prompt.default](optional, default is none)", default="", show_default=False ) - dir_config["raster_images"] = Prompt.ask( - " Which folder should manim find [bold]raster images[/bold] (.jpg .png .gif) in " + \ - "[prompt.default](optional, default is none)", - default="", + dir_config["subdirs"]["output"] = Prompt.ask( + " Within that base directory, which subdirectory should manim [bold]output[/bold] video and image files to?" + \ + " [prompt.default](optional, default is \"videos\")", + default="videos", show_default=False ) - dir_config["vector_images"] = Prompt.ask( - " Which folder should manim find [bold]vector images[/bold] (.svg .xdv) in " + \ - "[prompt.default](optional, default is none)", - default="", + dir_config["subdirs"]["raster_images"] = Prompt.ask( + " Within that base directory, which subdirectory should manim look for raster images (.png, .jpg)" + \ + " [prompt.default](optional, default is \"raster_images\")", + default="raster_images", show_default=False ) - dir_config["sounds"] = Prompt.ask( - " Which folder should manim find [bold]sound files[/bold] (.mp3 .wav) in " + \ - "[prompt.default](optional, default is none)", - default="", + dir_config["subdirs"]["vector_images"] = Prompt.ask( + " Within that base directory, which subdirectory should manim look for raster images (.svg, .xdv)" + \ + " [prompt.default](optional, default is \"vector_images\")", + default="vector_images", show_default=False ) - dir_config["temporary_storage"] = Prompt.ask( - " Which folder should manim storage [bold]temporary files[/bold] " + \ - "[prompt.default](recommended, use system temporary folder by default)", - default="", + dir_config["subdirs"]["sounds"] = Prompt.ask( + " Within that base directory, which subdirectory should manim look for sound files (.mp3, .wav)" + \ + " [prompt.default](optional, default is \"sounds\")", + default="sounds", + show_default=False + ) + dir_config["subdirs"]["downloads"] = Prompt.ask( + " Within that base directory, which subdirectory should manim output downloaded files" + \ + " [prompt.default](optional, default is \"downloads\")", + default="downloads", show_default=False ) From 3cd3e8cedcc36d472ed751f064d3b51c08d96e9d Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Thu, 5 Dec 2024 15:56:29 -0600 Subject: [PATCH 28/56] Add new pip requirements --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index 0737d3fc..cfd58290 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ +appdirs colour +diskcache ipython>=8.18.0 isosurfaces fontTools @@ -21,6 +23,7 @@ screeninfo skia-pathops svgelements>=1.8.1 sympy +tempfile tqdm typing-extensions; python_version < "3.11" validators From 49c2b5cfe03aacf1bcee7797710e94f825cf8662 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 08:51:08 -0600 Subject: [PATCH 29/56] Check if animation.mobject is in the full family of scene mobjects before adding --- manimlib/scene/scene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index abea0ba8..34ee13d7 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -623,7 +623,7 @@ class Scene(object): # animated mobjects that are in the family of # those on screen, this can result in a restructuring # of the scene.mobjects list, which is usually desired. - if animation.mobject not in self.mobjects: + if animation.mobject not in self.get_mobject_family_members(): self.add(animation.mobject) def progress_through_animations(self, animations: Iterable[Animation]) -> None: From 53b6c34ebec5a9c5478548ceb72adb5eec96b1da Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 09:39:12 -0600 Subject: [PATCH 30/56] Create Window outside of Scene, and pass it in as an argument --- manimlib/config.py | 6 ++++-- manimlib/reload_manager.py | 22 ++++++++++------------ manimlib/scene/scene.py | 34 +++++++++++++--------------------- manimlib/window.py | 35 +++++++++++++++++++++++++++++------ 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 4d0a7a99..426d40db 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -466,13 +466,11 @@ def get_scene_config(args: Namespace) -> dict: """ global_config = get_global_config() camera_config = get_camera_config(args, global_config) - window_config = get_window_config(args, global_config, camera_config) start, end = get_animations_numbers(args) return { "file_writer_config": get_file_writer_config(args, global_config), "camera_config": camera_config, - "window_config": window_config, "skip_animations": args.skip_animations, "start_at_animation_number": start, "end_at_animation_number": end, @@ -486,10 +484,14 @@ def get_scene_config(args: Namespace) -> dict: def get_run_config(args: Namespace): + global_config = get_global_config() + camera_config = get_camera_config(args, global_config) + window_config = get_window_config(args, global_config, camera_config) return { "module": get_scene_module(args), "prerun": args.prerun, "scene_names": args.scene_names, "quiet": args.quiet or args.write_all, "write_all": args.write_all, + "window_config": window_config, } diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index e7887930..96325755 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -1,6 +1,8 @@ from typing import Any from IPython.terminal.embed import KillEmbedded +from manimlib.window import Window + class ReloadManager: """ @@ -62,29 +64,25 @@ class ReloadManager: self.args.embed = str(overwrite_start_at_line) # Args to Config - self.args.is_reload = self.is_reload + self.args.is_reload = self.is_reload # Where is this used? scene_config = manimlib.config.get_scene_config(self.args) - if self.window: - scene_config["existing_window"] = self.window # see scene initialization - run_config = manimlib.config.get_run_config(self.args) + # Create or reuse window + if scene_config["preview"] and not self.window: # TODO, this should be in run_config not scene_config + self.window = Window(**run_config["window_config"]) + scene_config["window"] = self.window + # Scenes self.scenes = manimlib.extract_scene.main(scene_config, run_config) if len(self.scenes) == 0: print("No scenes found to run") return - # Find first available window for scene in self.scenes: - if scene.window is not None: - self.window = scene.window - break - - for scene in self.scenes: - if self.args.embed: + if self.args.embed and self.is_reload: print(" ".join([ - "Loading interactive session for", + "Reloading interactive session for", f"\033[96m{self.args.scene_names[0]}\033[0m", f"in \033[96m{self.args.file}\033[0m", f"at line \033[96m{self.args.embed}\033[0m" diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 34ee13d7..fa89b127 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -45,7 +45,7 @@ from manimlib.window import Window from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Callable, Iterable, TypeVar + from typing import Callable, Iterable, TypeVar, Optional from manimlib.typing import Vect3 T = TypeVar('T') @@ -68,7 +68,6 @@ class Scene(object): drag_to_pan: bool = True max_num_saved_states: int = 50 default_camera_config: dict = dict() - default_window_config: dict = dict() default_file_writer_config: dict = dict() samples = 0 # Euler angles, in degrees @@ -76,7 +75,6 @@ class Scene(object): def __init__( self, - window_config: dict = dict(), camera_config: dict = dict(), file_writer_config: dict = dict(), skip_animations: bool = False, @@ -84,12 +82,12 @@ class Scene(object): start_at_animation_number: int | None = None, end_at_animation_number: int | None = None, leave_progress_bars: bool = False, - preview: bool = True, + preview: bool = True, # TODO, remove + window: Optional[Window] = None, presenter_mode: bool = False, show_animation_progress: bool = False, embed_exception_mode: str = "", embed_error_sound: bool = False, - existing_window: Window | None = None, ): self.skip_animations = skip_animations self.always_update_mobjects = always_update_mobjects @@ -103,26 +101,20 @@ class Scene(object): self.embed_error_sound = embed_error_sound self.camera_config = {**self.default_camera_config, **camera_config} - self.window_config = {**self.default_window_config, **window_config} - for config in self.camera_config, self.window_config: - config["samples"] = self.samples self.file_writer_config = {**self.default_file_writer_config, **file_writer_config} - # Initialize window, if applicable (and reuse window if provided during - # reload by means of the ReloadManager) - if self.preview: - if existing_window: - self.window = existing_window - self.window.update_scene(self) - else: - self.window = Window(scene=self, **self.window_config) - self.camera_config["fps"] = 30 # Where's that 30 from? - self.camera_config["window"] = self.window - else: - self.window = None + self.window = window + if self.window: + self.window.init_for_scene(self) + # Make sure camera and Pyglet window sync + self.camera_config["fps"] = 30 # Core state of the scene - self.camera: Camera = Camera(**self.camera_config) + self.camera: Camera = Camera( + window=self.window, + samples=self.samples, + **self.camera_config + ) self.frame: CameraFrame = self.camera.frame self.frame.reorient(*self.default_frame_orientation) self.frame.make_orientation_default() diff --git a/manimlib/window.py b/manimlib/window.py index 4c7da0f5..e342b83d 100644 --- a/manimlib/window.py +++ b/manimlib/window.py @@ -14,7 +14,7 @@ from manimlib.constants import FRAME_SHAPE from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Callable, TypeVar + from typing import Callable, TypeVar, Optional from manimlib.scene.scene import Scene T = TypeVar("T") @@ -29,21 +29,22 @@ class Window(PygletWindow): def __init__( self, - scene: Scene, + scene: Optional[Scene] = None, size: tuple[int, int] = (1280, 720), samples: int = 0 ): - scene.window = self super().__init__(size=size, samples=samples) + self.scene = scene self.default_size = size self.default_position = self.find_initial_position(size) self.pressed_keys = set() self.size = size - self.update_scene(scene) + if self.scene: + self.init_for_scene(scene) - def update_scene(self, scene: Scene): + def init_for_scene(self, scene: Scene): """ Resets the state and updates the scene associated to this window. @@ -114,7 +115,7 @@ class Window(PygletWindow): py: int, relative: bool = False ) -> np.ndarray: - if not hasattr(self.scene, "frame"): + if self.scene is None or not hasattr(self.scene, "frame"): return np.zeros(3) pixel_shape = np.array(self.size) @@ -145,6 +146,8 @@ class Window(PygletWindow): @note_undrawn_event def on_mouse_motion(self, x: int, y: int, dx: int, dy: int) -> None: super().on_mouse_motion(x, y, dx, dy) + if not self.scene: + return point = self.pixel_coords_to_space_coords(x, y) d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True) self.scene.on_mouse_motion(point, d_point) @@ -152,6 +155,8 @@ class Window(PygletWindow): @note_undrawn_event def on_mouse_drag(self, x: int, y: int, dx: int, dy: int, buttons: int, modifiers: int) -> None: super().on_mouse_drag(x, y, dx, dy, buttons, modifiers) + if not self.scene: + return point = self.pixel_coords_to_space_coords(x, y) d_point = self.pixel_coords_to_space_coords(dx, dy, relative=True) self.scene.on_mouse_drag(point, d_point, buttons, modifiers) @@ -159,18 +164,24 @@ class Window(PygletWindow): @note_undrawn_event def on_mouse_press(self, x: int, y: int, button: int, mods: int) -> None: super().on_mouse_press(x, y, button, mods) + if not self.scene: + return point = self.pixel_coords_to_space_coords(x, y) self.scene.on_mouse_press(point, button, mods) @note_undrawn_event def on_mouse_release(self, x: int, y: int, button: int, mods: int) -> None: super().on_mouse_release(x, y, button, mods) + if not self.scene: + return point = self.pixel_coords_to_space_coords(x, y) self.scene.on_mouse_release(point, button, mods) @note_undrawn_event def on_mouse_scroll(self, x: int, y: int, x_offset: float, y_offset: float) -> None: super().on_mouse_scroll(x, y, x_offset, y_offset) + if not self.scene: + return point = self.pixel_coords_to_space_coords(x, y) offset = self.pixel_coords_to_space_coords(x_offset, y_offset, relative=True) self.scene.on_mouse_scroll(point, offset, x_offset, y_offset) @@ -179,32 +190,44 @@ class Window(PygletWindow): def on_key_press(self, symbol: int, modifiers: int) -> None: self.pressed_keys.add(symbol) # Modifiers? super().on_key_press(symbol, modifiers) + if not self.scene: + return self.scene.on_key_press(symbol, modifiers) @note_undrawn_event def on_key_release(self, symbol: int, modifiers: int) -> None: self.pressed_keys.difference_update({symbol}) # Modifiers? super().on_key_release(symbol, modifiers) + if not self.scene: + return self.scene.on_key_release(symbol, modifiers) @note_undrawn_event def on_resize(self, width: int, height: int) -> None: super().on_resize(width, height) + if not self.scene: + return self.scene.on_resize(width, height) @note_undrawn_event def on_show(self) -> None: super().on_show() + if not self.scene: + return self.scene.on_show() @note_undrawn_event def on_hide(self) -> None: super().on_hide() + if not self.scene: + return self.scene.on_hide() @note_undrawn_event def on_close(self) -> None: super().on_close() + if not self.scene: + return self.scene.on_close() def is_key_pressed(self, symbol: int) -> bool: From 33c7f6d063e20eedad2cafbf9df6233b1c6f6370 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 09:46:26 -0600 Subject: [PATCH 31/56] Factor out resolution from get_camera_config --- manimlib/config.py | 91 +++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 426d40db..e688142d 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -343,8 +343,8 @@ def get_animations_numbers(args: Namespace) -> tuple[int | None, int | None]: return int(stan), None -def get_output_directory(args: Namespace, custom_config: dict) -> str: - dir_config = custom_config["directories"] +def get_output_directory(args: Namespace, global_config: dict) -> str: + dir_config = global_config["directories"] output_directory = args.video_dir or dir_config["output"] if dir_config["mirror_module_path"] and args.file: to_cut = dir_config["removed_mirror_prefix"] @@ -356,7 +356,7 @@ def get_output_directory(args: Namespace, custom_config: dict) -> str: return output_directory -def get_file_writer_config(args: Namespace, custom_config: dict) -> dict: +def get_file_writer_config(args: Namespace, global_config: dict) -> dict: result = { "write_to_movie": not args.skip_animations and args.write_file, "save_last_frame": args.skip_animations and args.write_file, @@ -364,13 +364,13 @@ def get_file_writer_config(args: Namespace, custom_config: dict) -> dict: # If -t is passed in (for transparent), this will be RGBA "png_mode": "RGBA" if args.transparent else "RGB", "movie_file_extension": get_file_ext(args), - "output_directory": get_output_directory(args, custom_config), + "output_directory": get_output_directory(args, global_config), "file_name": args.file_name, "input_file_path": args.file or "", "open_file_upon_completion": args.open, "show_file_location_upon_completion": args.finder, "quiet": args.quiet, - **custom_config["file_writer_config"], + **global_config["file_writer_config"], } if args.vcodec: @@ -387,32 +387,11 @@ def get_file_writer_config(args: Namespace, custom_config: dict) -> dict: return result -def get_window_config(args: Namespace, custom_config: dict, camera_config: dict) -> dict: - # Default to making window half the screen size - # but make it full screen if -f is passed in - try: - monitors = screeninfo.get_monitors() - except screeninfo.ScreenInfoError: - # Default fallback - monitors = [screeninfo.Monitor(width=1920, height=1080)] - mon_index = custom_config["window_monitor"] - monitor = monitors[min(mon_index, len(monitors) - 1)] - aspect_ratio = camera_config["pixel_width"] / camera_config["pixel_height"] - window_width = monitor.width - if not (args.full_screen or custom_config["full_screen"]): - window_width //= 2 - window_height = int(window_width / aspect_ratio) - return dict(size=(window_width, window_height)) +def get_resolution(args: Optional[Namespace] = None, global_config: Optional[dict] = None): + args = args or parse_cli() + global_config = global_config or get_global_config() - -def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[dict] = None) -> dict: - if args is None: - args = parse_cli() - if custom_config is None: - custom_config = get_global_config() - - camera_config = dict() - camera_resolutions = custom_config["camera_resolutions"] + camera_resolutions = global_config["camera_resolutions"] if args.resolution: resolution = args.resolution elif args.low_quality: @@ -426,33 +405,56 @@ def get_camera_config(args: Optional[Namespace] = None, custom_config: Optional[ else: resolution = camera_resolutions[camera_resolutions["default_resolution"]] - if args.fps: - fps = int(args.fps) - else: - fps = custom_config["fps"] - width_str, height_str = resolution.split("x") - width = int(width_str) - height = int(height_str) + return int(width_str), int(height_str) - camera_config.update({ + +def get_window_config(args: Namespace, global_config: dict) -> dict: + # Default to making window half the screen size + # but make it full screen if -f is passed in + try: + monitors = screeninfo.get_monitors() + except screeninfo.ScreenInfoError: + # Default fallback + monitors = [screeninfo.Monitor(width=1920, height=1080)] + mon_index = global_config["window_monitor"] + monitor = monitors[min(mon_index, len(monitors) - 1)] + + width, height = get_resolution(args, global_config) + + aspect_ratio = width / height + window_width = monitor.width + if not (args.full_screen or global_config["full_screen"]): + window_width //= 2 + window_height = int(window_width / aspect_ratio) + return dict(size=(window_width, window_height)) + + +def get_camera_config(args: Optional[Namespace] = None, global_config: Optional[dict] = None) -> dict: + args = args or parse_cli() + global_config = global_config or get_global_config() + + width, height = get_resolution(args, global_config) + fps = int(args.fps or global_config["fps"]) + + camera_config = { "pixel_width": width, "pixel_height": height, "frame_config": { "frame_shape": ((width / height) * FRAME_HEIGHT, FRAME_HEIGHT), }, "fps": fps, - }) + } try: - bg_color = args.color or custom_config["style"]["background_color"] + bg_color = args.color or global_config["style"]["background_color"] camera_config["background_color"] = colour.Color(bg_color) except ValueError as err: log.error("Please use a valid color") log.error(err) sys.exit(2) - # If rendering a transparent image/move, make sure the + # If rendering a transparent image/movie, make sure the # scene has a background opacity of 0 if args.transparent: camera_config["background_opacity"] = 0 @@ -466,10 +468,11 @@ def get_scene_config(args: Namespace) -> dict: """ global_config = get_global_config() camera_config = get_camera_config(args, global_config) + file_writer_config = get_file_writer_config(args, global_config) start, end = get_animations_numbers(args) return { - "file_writer_config": get_file_writer_config(args, global_config), + "file_writer_config": file_writer_config, "camera_config": camera_config, "skip_animations": args.skip_animations, "start_at_animation_number": start, @@ -484,9 +487,7 @@ def get_scene_config(args: Namespace) -> dict: def get_run_config(args: Namespace): - global_config = get_global_config() - camera_config = get_camera_config(args, global_config) - window_config = get_window_config(args, global_config, camera_config) + window_config = get_window_config(args, get_global_config()) return { "module": get_scene_module(args), "prerun": args.prerun, From 2e49c60148b492c1d7a0f5ef1562bef03de25e28 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 09:49:21 -0600 Subject: [PATCH 32/56] Use config.get_resolution for constants --- manimlib/constants.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/manimlib/constants.py b/manimlib/constants.py index 7850796c..4b67cdb8 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -1,7 +1,7 @@ from __future__ import annotations import numpy as np -from manimlib.config import get_camera_config +from manimlib.config import get_resolution from manimlib.config import FRAME_HEIGHT from typing import TYPE_CHECKING @@ -13,18 +13,18 @@ if TYPE_CHECKING: # TODO, it feels a bit unprincipled to have some global constants # depend on the output of this function, and for all that configuration # code to be run merely upon importing from this file. -CAMERA_CONFIG = get_camera_config() +DEFAULT_RESOLUTION: tuple[int, int] = get_resolution() +DEFAULT_PIXEL_WIDTH = DEFAULT_RESOLUTION[0] +DEFAULT_PIXEL_HEIGHT = DEFAULT_RESOLUTION[1] +DEFAULT_FPS: int = 30 # Sizes relevant to default camera frame -ASPECT_RATIO: float = CAMERA_CONFIG['pixel_width'] / CAMERA_CONFIG['pixel_height'] +ASPECT_RATIO: float = DEFAULT_PIXEL_WIDTH / DEFAULT_PIXEL_HEIGHT FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT) FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2 FRAME_X_RADIUS: float = FRAME_WIDTH / 2 -DEFAULT_PIXEL_HEIGHT: int = CAMERA_CONFIG['pixel_height'] -DEFAULT_PIXEL_WIDTH: int = CAMERA_CONFIG['pixel_width'] -DEFAULT_FPS: int = 30 SMALL_BUFF: float = 0.1 MED_SMALL_BUFF: float = 0.25 From dd251ab8c2fa3e387064402261b00dbc588216bc Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 09:54:14 -0600 Subject: [PATCH 33/56] Remove "preview" as a scene parameter, just look for whether window is None --- manimlib/config.py | 2 +- manimlib/reload_manager.py | 2 +- manimlib/scene/scene.py | 8 ++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index e688142d..87120480 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -477,7 +477,6 @@ def get_scene_config(args: Namespace) -> dict: "skip_animations": args.skip_animations, "start_at_animation_number": start, "end_at_animation_number": end, - "preview": not args.write_file, "presenter_mode": args.presenter_mode, "leave_progress_bars": args.leave_progress_bars, "show_animation_progress": args.show_animation_progress, @@ -495,4 +494,5 @@ def get_run_config(args: Namespace): "quiet": args.quiet or args.write_all, "write_all": args.write_all, "window_config": window_config, + "show_in_window": not args.write_file } diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index 96325755..a8446873 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -69,7 +69,7 @@ class ReloadManager: run_config = manimlib.config.get_run_config(self.args) # Create or reuse window - if scene_config["preview"] and not self.window: # TODO, this should be in run_config not scene_config + if run_config["show_in_window"] and not self.window: self.window = Window(**run_config["window_config"]) scene_config["window"] = self.window diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index fa89b127..09dfa969 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -82,7 +82,6 @@ class Scene(object): start_at_animation_number: int | None = None, end_at_animation_number: int | None = None, leave_progress_bars: bool = False, - preview: bool = True, # TODO, remove window: Optional[Window] = None, presenter_mode: bool = False, show_animation_progress: bool = False, @@ -94,7 +93,6 @@ class Scene(object): self.start_at_animation_number = start_at_animation_number self.end_at_animation_number = end_at_animation_number self.leave_progress_bars = leave_progress_bars - self.preview = preview self.presenter_mode = presenter_mode self.show_animation_progress = show_animation_progress self.embed_exception_mode = embed_exception_mode @@ -212,8 +210,8 @@ class Scene(object): close_scene_on_exit: bool = True, show_animation_progress: bool = False, ) -> None: - if not self.preview: - # Embed is only relevant with a preview + if not self.window: + # Embed is only relevant for interactive development with a Window return self.stop_skipping() self.update_frame(force_draw=True) @@ -728,8 +726,6 @@ class Scene(object): scene_state.restore_scene(self) def save_state(self) -> None: - if not self.preview: - return state = self.get_state() if self.undo_stack and state.mobjects_match(self.undo_stack[-1]): return From d357e21c1d7fb4c8fb97f8bc885790dc0da254f5 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 10:07:07 -0600 Subject: [PATCH 34/56] Change how ModuleLoader receives is_reload information Use on the fly import of reload_manager rather than altering the args --- manimlib/config.py | 8 ++++---- manimlib/reload_manager.py | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 87120480..cd6dd4a7 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -215,7 +215,7 @@ def get_indent(line: str): def get_module_with_inserted_embed_line( - file_name: str, scene_name: str, line_marker: str, is_during_reload + file_name: str, scene_name: str, line_marker: str ): """ This is hacky, but convenient. When user includes the argument "-e", it will try @@ -277,7 +277,8 @@ def get_module_with_inserted_embed_line( with open(new_file, 'w') as fp: fp.writelines(new_lines) - module = ModuleLoader.get_module(new_file, is_during_reload) + from manimlib.reload_manager import reload_manager + module = ModuleLoader.get_module(new_file, is_during_reload=reload_manager.is_reload) # This is to pretend the module imported from the edited lines # of code actually comes from the original file. module.__file__ = file_name @@ -291,9 +292,8 @@ def get_scene_module(args: Namespace) -> Module: if args.embed is None: return ModuleLoader.get_module(args.file) else: - is_reload = args.is_reload if hasattr(args, "is_reload") else False return get_module_with_inserted_embed_line( - args.file, args.scene_names[0], args.embed, is_reload + args.file, args.scene_names[0], args.embed ) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index a8446873..e8f7f485 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -64,7 +64,6 @@ class ReloadManager: self.args.embed = str(overwrite_start_at_line) # Args to Config - self.args.is_reload = self.is_reload # Where is this used? scene_config = manimlib.config.get_scene_config(self.args) run_config = manimlib.config.get_run_config(self.args) From dd0aa14442517fab904deaedde5ec14ba4a7551a Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 10:39:02 -0600 Subject: [PATCH 35/56] Clean up get_module_with_inserted_embed_line, only accept line number as embed arg --- manimlib/config.py | 79 ++++++++++++++-------------------------------- 1 file changed, 24 insertions(+), 55 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index cd6dd4a7..b4420a92 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -9,6 +9,7 @@ import os import screeninfo import sys import yaml +from pathlib import Path from functools import lru_cache @@ -144,12 +145,8 @@ def parse_cli(): ) parser.add_argument( "-e", "--embed", - nargs="?", - const="", help="Creates a new file where the line `self.embed` is inserted " + \ - "into the Scenes construct method. " + \ - "If a string is passed in, the line will be inserted below the " + \ - "last line of code including that string." + "at the corresponding line number" ) parser.add_argument( "-r", "--resolution", @@ -210,12 +207,19 @@ def get_manim_dir(): return os.path.abspath(os.path.join(manimlib_dir, "..")) -def get_indent(line: str): - return len(line) - len(line.lstrip()) +def get_indent(code_lines: list[str], line_number: int): + for line in code_lines[line_number:0:-1]: + if len(line.strip()) == 0: + continue + n_spaces = len(line) - len(line.lstrip()) + if line.endswith(":"): + n_spaces += 4 + return n_spaces + return 0 def get_module_with_inserted_embed_line( - file_name: str, scene_name: str, line_marker: str + file_name: str, scene_name: str, line_number: int ): """ This is hacky, but convenient. When user includes the argument "-e", it will try @@ -223,59 +227,24 @@ def get_module_with_inserted_embed_line( construct method. If there is an argument passed in, it will insert the line after the last line in the sourcefile which includes that string. """ - with open(file_name, 'r') as fp: - lines = fp.readlines() + lines = Path(file_name).read_text().splitlines() - try: - scene_line_number = next( - i for i, line in enumerate(lines) - if line.startswith(f"class {scene_name}") - ) - except StopIteration: + scene_line_numbers = [ + n for n, line in enumerate(lines) + if line.startswith("class SurfaceTest") + ] + if len(scene_line_numbers) == 0: log.error(f"No scene {scene_name}") return + scene_line_number = scene_line_numbers[0] - prev_line_num = -1 - n_spaces = None - if len(line_marker) == 0: - # Find the end of the construct method - in_construct = False - for index in range(scene_line_number, len(lines) - 1): - line = lines[index] - if line.lstrip().startswith("def construct"): - in_construct = True - n_spaces = get_indent(line) + 4 - elif in_construct: - if len(line.strip()) > 0 and get_indent(line) < (n_spaces or 0): - prev_line_num = index - 1 - break - if prev_line_num < 0: - prev_line_num = len(lines) - 1 - elif line_marker.isdigit(): - # Treat the argument as a line number - prev_line_num = int(line_marker) - 1 - elif len(line_marker) > 0: - # Treat the argument as a string - try: - prev_line_num = next( - i - for i in range(scene_line_number, len(lines) - 1) - if line_marker in lines[i] - ) - except StopIteration: - log.error(f"No lines matching {line_marker}") - sys.exit(2) - - # Insert the embed line, rewrite file, then write it back when done - if n_spaces is None: - n_spaces = get_indent(lines[prev_line_num]) - inserted_line = " " * n_spaces + "self.embed()\n" + n_spaces = get_indent(lines, line_number - 1) + inserted_line = " " * n_spaces + "self.embed()" new_lines = list(lines) - new_lines.insert(prev_line_num + 1, inserted_line) + new_lines.insert(line_number, inserted_line) new_file = file_name.replace(".py", "_insert_embed.py") - with open(new_file, 'w') as fp: - fp.writelines(new_lines) + Path(new_file).write_text("\n".join(new_lines)) from manimlib.reload_manager import reload_manager module = ModuleLoader.get_module(new_file, is_during_reload=reload_manager.is_reload) @@ -293,7 +262,7 @@ def get_scene_module(args: Namespace) -> Module: return ModuleLoader.get_module(args.file) else: return get_module_with_inserted_embed_line( - args.file, args.scene_names[0], args.embed + args.file, args.scene_names[0], int(args.embed) ) From fadd045fc1e3d41f0be8b8501f8f5fb44ec8ae3c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 11:05:57 -0700 Subject: [PATCH 36/56] Don't write new file when inserting embed line Instead, load the relevant module of the true file, and execute the modified code within that. This also cleans up some of the previous now-unnecessary code around get_module_with_inserted_embed_line --- manimlib/config.py | 47 +++++++++++++-------------------------- manimlib/module_loader.py | 3 --- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index b4420a92..80b5c2f7 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -207,20 +207,18 @@ def get_manim_dir(): return os.path.abspath(os.path.join(manimlib_dir, "..")) -def get_indent(code_lines: list[str], line_number: int): - for line in code_lines[line_number:0:-1]: +def get_indent(code_lines: list[str], line_number: int) -> str: + for line in code_lines[line_number - 1::-1]: if len(line.strip()) == 0: continue n_spaces = len(line) - len(line.lstrip()) if line.endswith(":"): n_spaces += 4 - return n_spaces - return 0 + return n_spaces * " " + return "" -def get_module_with_inserted_embed_line( - file_name: str, scene_name: str, line_number: int -): +def get_module_with_inserted_embed_line(file_name: str, line_number: int): """ This is hacky, but convenient. When user includes the argument "-e", it will try to recreate a file that inserts the line `self.embed()` into the end of the scene's @@ -229,31 +227,18 @@ def get_module_with_inserted_embed_line( """ lines = Path(file_name).read_text().splitlines() - scene_line_numbers = [ - n for n, line in enumerate(lines) - if line.startswith("class SurfaceTest") - ] - if len(scene_line_numbers) == 0: - log.error(f"No scene {scene_name}") - return - scene_line_number = scene_line_numbers[0] - - n_spaces = get_indent(lines, line_number - 1) - inserted_line = " " * n_spaces + "self.embed()" - new_lines = list(lines) - new_lines.insert(line_number, inserted_line) - new_file = file_name.replace(".py", "_insert_embed.py") - - Path(new_file).write_text("\n".join(new_lines)) + # Add the relevant embed line to the code + indent = get_indent(lines, line_number) + lines.insert(line_number, indent + "self.embed()") + new_code = "\n".join(lines) + # Load the module for the original file, then exectue the new code within + # it, which should redefined the scene to have the inserted embed line from manimlib.reload_manager import reload_manager - module = ModuleLoader.get_module(new_file, is_during_reload=reload_manager.is_reload) - # This is to pretend the module imported from the edited lines - # of code actually comes from the original file. - module.__file__ = file_name - - os.remove(new_file) + module = ModuleLoader.get_module(file_name, is_during_reload=reload_manager.is_reload) + code_object = compile(new_code, module.__name__, 'exec') + exec(code_object, module.__dict__) return module @@ -261,9 +246,7 @@ def get_scene_module(args: Namespace) -> Module: if args.embed is None: return ModuleLoader.get_module(args.file) else: - return get_module_with_inserted_embed_line( - args.file, args.scene_names[0], int(args.embed) - ) + return get_module_with_inserted_embed_line(args.file, int(args.embed)) def load_yaml(file_path: str): diff --git a/manimlib/module_loader.py b/manimlib/module_loader.py index 894f12a0..e43f8973 100644 --- a/manimlib/module_loader.py +++ b/manimlib/module_loader.py @@ -76,10 +76,7 @@ class ModuleLoader: builtins.__import__ = tracked_import try: - # Remove the "_insert_embed" suffix from the module name module_name = module.__name__ - if module.__name__.endswith("_insert_embed"): - module_name = module_name[:-13] log.debug('Reloading module "%s"', module_name) spec.loader.exec_module(module) From e270f5c3d3cb545f1721533241ae1859d418f663 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 11:59:18 -0700 Subject: [PATCH 37/56] Change from get_module_with_inserted_embed_line to insert_embed_line_to_module Rather than taking in a file_name and reading it in, directly take the module and edit its code. --- manimlib/config.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index 80b5c2f7..f28bdfe0 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -218,36 +218,31 @@ def get_indent(code_lines: list[str], line_number: int) -> str: return "" -def get_module_with_inserted_embed_line(file_name: str, line_number: int): +def insert_embed_line_to_module(module: Module, line_number: int): """ This is hacky, but convenient. When user includes the argument "-e", it will try to recreate a file that inserts the line `self.embed()` into the end of the scene's construct method. If there is an argument passed in, it will insert the line after the last line in the sourcefile which includes that string. """ - lines = Path(file_name).read_text().splitlines() + lines = inspect.getsource(module).splitlines() # Add the relevant embed line to the code indent = get_indent(lines, line_number) lines.insert(line_number, indent + "self.embed()") new_code = "\n".join(lines) - # Load the module for the original file, then exectue the new code within - # it, which should redefined the scene to have the inserted embed line - from manimlib.reload_manager import reload_manager - module = ModuleLoader.get_module(file_name, is_during_reload=reload_manager.is_reload) - code_object = compile(new_code, module.__name__, 'exec') exec(code_object, module.__dict__) return module def get_scene_module(args: Namespace) -> Module: - if args.embed is None: - return ModuleLoader.get_module(args.file) - else: - return get_module_with_inserted_embed_line(args.file, int(args.embed)) - + from manimlib.reload_manager import reload_manager + module = ModuleLoader.get_module(args.file, is_during_reload=reload_manager.is_reload) + if args.embed: + insert_embed_line_to_module(module, int(args.embed)) + return module def load_yaml(file_path: str): try: From 90dfb02cc668e3ad3df6ca7f975e83be75007520 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 12:24:16 -0700 Subject: [PATCH 38/56] Move get_scene_module logic to extract_scene.py --- manimlib/config.py | 43 +++-------------------------- manimlib/extract_scene.py | 55 ++++++++++++++++++++++++++++++++++++-- manimlib/reload_manager.py | 3 ++- 3 files changed, 58 insertions(+), 43 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index f28bdfe0..b64f4b7f 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -14,13 +14,11 @@ from pathlib import Path from functools import lru_cache from manimlib.logger import log -from manimlib.module_loader import ModuleLoader from manimlib.utils.dict_ops import merge_dicts_recursively from manimlib.utils.init_config import init_customization from typing import TYPE_CHECKING if TYPE_CHECKING: - Module = importlib.util.types.ModuleType from typing import Optional @@ -207,43 +205,6 @@ def get_manim_dir(): return os.path.abspath(os.path.join(manimlib_dir, "..")) -def get_indent(code_lines: list[str], line_number: int) -> str: - for line in code_lines[line_number - 1::-1]: - if len(line.strip()) == 0: - continue - n_spaces = len(line) - len(line.lstrip()) - if line.endswith(":"): - n_spaces += 4 - return n_spaces * " " - return "" - - -def insert_embed_line_to_module(module: Module, line_number: int): - """ - This is hacky, but convenient. When user includes the argument "-e", it will try - to recreate a file that inserts the line `self.embed()` into the end of the scene's - construct method. If there is an argument passed in, it will insert the line after - the last line in the sourcefile which includes that string. - """ - lines = inspect.getsource(module).splitlines() - - # Add the relevant embed line to the code - indent = get_indent(lines, line_number) - lines.insert(line_number, indent + "self.embed()") - new_code = "\n".join(lines) - - code_object = compile(new_code, module.__name__, 'exec') - exec(code_object, module.__dict__) - return module - - -def get_scene_module(args: Namespace) -> Module: - from manimlib.reload_manager import reload_manager - module = ModuleLoader.get_module(args.file, is_during_reload=reload_manager.is_reload) - if args.embed: - insert_embed_line_to_module(module, int(args.embed)) - return module - def load_yaml(file_path: str): try: with open(file_path, "r") as file: @@ -435,7 +396,9 @@ def get_scene_config(args: Namespace) -> dict: def get_run_config(args: Namespace): window_config = get_window_config(args, get_global_config()) return { - "module": get_scene_module(args), + "file_name": args.file, + "embed_line": int(args.embed) if args.embed is not None else None, + "is_reload": False, "prerun": args.prerun, "scene_names": args.scene_names, "quiet": args.quiet or args.write_all, diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 15dd3e13..2aa5c900 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -1,12 +1,21 @@ +from __future__ import annotations + import copy import inspect import sys +from manimlib.module_loader import ModuleLoader + from manimlib.config import get_global_config from manimlib.logger import log from manimlib.scene.interactive_scene import InteractiveScene from manimlib.scene.scene import Scene +from typing import TYPE_CHECKING +if TYPE_CHECKING: + Module = importlib.util.types.ModuleType + from typing import Optional + class BlankScene(InteractiveScene): def construct(self): @@ -115,10 +124,52 @@ def get_scene_classes_from_module(module): ] +def get_indent(code_lines: list[str], line_number: int) -> str: + for line in code_lines[line_number - 1::-1]: + if len(line.strip()) == 0: + continue + n_spaces = len(line) - len(line.lstrip()) + if line.endswith(":"): + n_spaces += 4 + return n_spaces * " " + return "" + + +def insert_embed_line_to_module(module: Module, line_number: int): + """ + This is hacky, but convenient. When user includes the argument "-e", it will try + to recreate a file that inserts the line `self.embed()` into the end of the scene's + construct method. If there is an argument passed in, it will insert the line after + the last line in the sourcefile which includes that string. + """ + lines = inspect.getsource(module).splitlines() + + # Add the relevant embed line to the code + indent = get_indent(lines, line_number) + lines.insert(line_number, indent + "self.embed()") + new_code = "\n".join(lines) + + code_object = compile(new_code, module.__name__, 'exec') + exec(code_object, module.__dict__) + return module + + +def get_scene_module(file_name: Optional[str], embed_line: Optional[int], is_reload: bool = False) -> Module: + module = ModuleLoader.get_module(file_name, is_reload) + if embed_line: + insert_embed_line_to_module(module, embed_line) + return module + + def main(scene_config, run_config): - if run_config["module"] is None: + module = get_scene_module( + run_config["file_name"], + run_config["embed_line"], + run_config["is_reload"] + ) + if module is None: # If no module was passed in, just play the blank scene return [BlankScene(**scene_config)] - all_scene_classes = get_scene_classes_from_module(run_config["module"]) + all_scene_classes = get_scene_classes_from_module(module) return get_scenes_to_render(all_scene_classes, scene_config, run_config) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index e8f7f485..7e0f7228 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -66,11 +66,12 @@ class ReloadManager: # Args to Config scene_config = manimlib.config.get_scene_config(self.args) run_config = manimlib.config.get_run_config(self.args) + run_config.update(is_reload=self.is_reload) # Create or reuse window if run_config["show_in_window"] and not self.window: self.window = Window(**run_config["window_config"]) - scene_config["window"] = self.window + scene_config.update(window=self.window) # Scenes self.scenes = manimlib.extract_scene.main(scene_config, run_config) From 09c27a654f6382be70bc152de7d9435d3c6db989 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 12:26:54 -0700 Subject: [PATCH 39/56] Minor cleaning of imports --- manimlib/config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index b64f4b7f..c3c2aa62 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -from argparse import Namespace import colour import importlib import inspect @@ -9,7 +8,6 @@ import os import screeninfo import sys import yaml -from pathlib import Path from functools import lru_cache @@ -19,6 +17,7 @@ from manimlib.utils.init_config import init_customization from typing import TYPE_CHECKING if TYPE_CHECKING: + from argparse import Namespace from typing import Optional From 0ef12ad7e469b085e59e476bdd1deced07db4fb5 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Fri, 6 Dec 2024 12:35:39 -0700 Subject: [PATCH 40/56] Move FRAME_HEIGHT back to constants Where it belongs --- manimlib/config.py | 8 -------- manimlib/constants.py | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/manimlib/config.py b/manimlib/config.py index c3c2aa62..19f731e6 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -21,11 +21,6 @@ if TYPE_CHECKING: from typing import Optional -# This has to be here instead of in constants.py -# due to its use in creating the camera configuration -FRAME_HEIGHT: float = 8.0 - - def parse_cli(): try: parser = argparse.ArgumentParser() @@ -347,9 +342,6 @@ def get_camera_config(args: Optional[Namespace] = None, global_config: Optional[ camera_config = { "pixel_width": width, "pixel_height": height, - "frame_config": { - "frame_shape": ((width / height) * FRAME_HEIGHT, FRAME_HEIGHT), - }, "fps": fps, } diff --git a/manimlib/constants.py b/manimlib/constants.py index 4b67cdb8..4dcdb508 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -2,7 +2,6 @@ from __future__ import annotations import numpy as np from manimlib.config import get_resolution -from manimlib.config import FRAME_HEIGHT from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -20,6 +19,7 @@ DEFAULT_FPS: int = 30 # Sizes relevant to default camera frame ASPECT_RATIO: float = DEFAULT_PIXEL_WIDTH / DEFAULT_PIXEL_HEIGHT +FRAME_HEIGHT: float = 8.0 FRAME_WIDTH: float = FRAME_HEIGHT * ASPECT_RATIO FRAME_SHAPE: tuple[float, float] = (FRAME_WIDTH, FRAME_HEIGHT) FRAME_Y_RADIUS: float = FRAME_HEIGHT / 2 From 5fa99b772398aaad527ba11c4418f9ffcada4871 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Sat, 7 Dec 2024 08:14:56 -0700 Subject: [PATCH 41/56] Set default log level to "WARNING" --- manimlib/logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manimlib/logger.py b/manimlib/logger.py index 71567a1d..5ef12121 100644 --- a/manimlib/logger.py +++ b/manimlib/logger.py @@ -11,4 +11,4 @@ logging.basicConfig( ) log = logging.getLogger("manimgl") -log.setLevel("DEBUG") +log.setLevel("WARNING") From 89bf0b129732726e7eea433f96b0b1a272e2c794 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Sat, 7 Dec 2024 08:21:33 -0700 Subject: [PATCH 42/56] Track all mobjects as a set in Scene. begin_animations --- manimlib/scene/scene.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 09dfa969..d35aa7c2 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -606,6 +606,7 @@ class Scene(object): self.num_plays += 1 def begin_animations(self, animations: Iterable[Animation]) -> None: + all_mobjects = set(self.get_mobject_family_members()) for animation in animations: animation.begin() # Anything animated that's not already in the @@ -613,8 +614,9 @@ class Scene(object): # animated mobjects that are in the family of # those on screen, this can result in a restructuring # of the scene.mobjects list, which is usually desired. - if animation.mobject not in self.get_mobject_family_members(): + if animation.mobject not in all_mobjects: self.add(animation.mobject) + all_mobjects = all_mobjects.union(animation.mobject.get_family()) def progress_through_animations(self, animations: Iterable[Animation]) -> None: last_t = 0 From 14c6fdc1d9941044d7bc100f1b4fd1d581e2011a Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 09:49:48 -0600 Subject: [PATCH 43/56] Slight refactor of get_indent --- manimlib/extract_scene.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 2aa5c900..80d5654c 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -125,14 +125,21 @@ def get_scene_classes_from_module(module): def get_indent(code_lines: list[str], line_number: int) -> str: - for line in code_lines[line_number - 1::-1]: - if len(line.strip()) == 0: - continue - n_spaces = len(line) - len(line.lstrip()) - if line.endswith(":"): - n_spaces += 4 - return n_spaces * " " - return "" + """ + Find the indent associated with a given line of python code, + as a string of spaces + """ + # Find most recent non-empty line + try: + next(filter(lambda line: line.strip(), code_lines[line_number - 1::-1])) + except StopIteration: + return "" + + # Either return its leading spaces, or add for if it ends with colon + n_spaces = len(line) - len(line.lstrip()) + if line.endswith(":"): + n_spaces += 4 + return n_spaces * " " def insert_embed_line_to_module(module: Module, line_number: int): @@ -149,9 +156,10 @@ def insert_embed_line_to_module(module: Module, line_number: int): lines.insert(line_number, indent + "self.embed()") new_code = "\n".join(lines) + # Execute the code, which presumably redefines the user's + # scene to include this embed line, within the relevant module. code_object = compile(new_code, module.__name__, 'exec') exec(code_object, module.__dict__) - return module def get_scene_module(file_name: Optional[str], embed_line: Optional[int], is_reload: bool = False) -> Module: From 0692afdfec648ee6889bdadc6beeb4cff10d7fa3 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 11:59:16 -0600 Subject: [PATCH 44/56] Bug fix --- manimlib/extract_scene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index 80d5654c..2b78c974 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -131,7 +131,7 @@ def get_indent(code_lines: list[str], line_number: int) -> str: """ # Find most recent non-empty line try: - next(filter(lambda line: line.strip(), code_lines[line_number - 1::-1])) + line = next(filter(lambda line: line.strip(), code_lines[line_number - 1::-1])) except StopIteration: return "" From ea3f77e3f1a14456fa2e182411449bdf75e035ab Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 11:59:22 -0600 Subject: [PATCH 45/56] Add blank line --- manimlib/reload_manager.py | 1 + 1 file changed, 1 insertion(+) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index 7e0f7228..8b8db095 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -89,4 +89,5 @@ class ReloadManager: ])) scene.run() + reload_manager = ReloadManager() From 636fb3a45b4726ca308043a665b60f4e9efc5e2d Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 13:53:03 -0600 Subject: [PATCH 46/56] Factor interactive embed logic out of Scene class --- manimlib/scene/scene.py | 134 ++----------------------- manimlib/scene/scene_embed.py | 178 ++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 128 deletions(-) create mode 100644 manimlib/scene/scene_embed.py diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index d35aa7c2..df1a4a38 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -1,41 +1,34 @@ from __future__ import annotations from collections import OrderedDict -import inspect import os import platform -import pyperclip import random import time -import re from functools import wraps -from IPython.terminal import pt_inputhooks -from IPython.terminal.embed import InteractiveShellEmbed from pyglet.window import key as PygletWindowKeys import numpy as np from tqdm.auto import tqdm as ProgressDisplay from manimlib.animation.animation import prepare_animation -from manimlib.animation.fading import VFadeInThenOut from manimlib.camera.camera import Camera from manimlib.camera.camera_frame import CameraFrame -from manimlib.module_loader import ModuleLoader from manimlib.constants import ARROW_SYMBOLS from manimlib.constants import DEFAULT_WAIT_TIME -from manimlib.constants import RED from manimlib.event_handler import EVENT_DISPATCHER from manimlib.event_handler.event_type import EventType from manimlib.logger import log from manimlib.reload_manager import reload_manager -from manimlib.mobject.frame import FullScreenRectangle from manimlib.mobject.mobject import _AnimationBuilder from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Point from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VMobject +from manimlib.scene.scene_embed import interactive_scene_embed +from manimlib.scene.scene_embed import CheckpointManager from manimlib.scene.scene_file_writer import SceneFileWriter from manimlib.utils.family_ops import extract_mobject_family_members from manimlib.utils.family_ops import recursive_mobject_remove @@ -126,6 +119,7 @@ class Scene(object): self.skip_time: float = 0 self.original_skipping_status: bool = self.skip_animations self.checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict() + self.checkpoint_manager: CheckpointManager = CheckpointManager() self.undo_stack = [] self.redo_stack = [] @@ -210,80 +204,9 @@ class Scene(object): close_scene_on_exit: bool = True, show_animation_progress: bool = False, ) -> None: - if not self.window: - # Embed is only relevant for interactive development with a Window - return - self.stop_skipping() - self.update_frame(force_draw=True) - self.save_state() self.show_animation_progress = show_animation_progress - # Create embedded IPython terminal configured to have access to - # the local namespace of the caller - caller_frame = inspect.currentframe().f_back - module = ModuleLoader.get_module(caller_frame.f_globals["__file__"]) - shell = InteractiveShellEmbed( - user_module=module, - display_banner=False, - xmode=self.embed_exception_mode - ) - self.shell = shell - - # Add a few custom shortcuts to that local namespace - local_ns = dict(caller_frame.f_locals) - local_ns.update( - play=self.play, - wait=self.wait, - add=self.add, - remove=self.remove, - clear=self.clear, - focus=self.focus, - save_state=self.save_state, - reload=self.reload, - undo=self.undo, - redo=self.redo, - i2g=self.i2g, - i2m=self.i2m, - checkpoint_paste=self.checkpoint_paste, - touch=lambda: shell.enable_gui("manim"), - notouch=lambda: shell.enable_gui(None), - ) - - # Update the shell module with the caller's locals + shortcuts - module.__dict__.update(local_ns) - - # Enables gui interactions during the embed - def inputhook(context): - while not context.input_is_ready(): - if not self.is_window_closing(): - self.update_frame(dt=0) - if self.is_window_closing(): - shell.ask_exit() - - pt_inputhooks.register("manim", inputhook) - shell.enable_gui("manim") - - # Operation to run after each ipython command - def post_cell_func(*args, **kwargs): - if not self.is_window_closing(): - self.update_frame(dt=0, force_draw=True) - - shell.events.register("post_run_cell", post_cell_func) - - # Flash border, and potentially play sound, on exceptions - def custom_exc(shell, etype, evalue, tb, tb_offset=None): - # Show the error don't just swallow it - shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset) - if self.embed_error_sound: - os.system("printf '\a'") - rect = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0) - rect.fix_in_frame() - self.play(VFadeInThenOut(rect, run_time=0.5)) - - shell.set_custom_exc((Exception,), custom_exc) - - # Launch shell - shell() + interactive_scene_embed(self) # End scene when exiting an embed if close_scene_on_exit: @@ -760,37 +683,6 @@ class Scene(object): revert to the state of the scene the first time this function was called on a block of code starting with that comment. """ - if self.shell is None or self.window is None: - raise Exception( - "Scene.checkpoint_paste cannot be called outside of " + - "an ipython shell" - ) - - pasted = pyperclip.paste() - lines = pasted.split("\n") - - # Commented lines trigger saved checkpoints - if lines[0].lstrip().startswith("#"): - if lines[0] not in self.checkpoint_states: - self.checkpoint(lines[0]) - else: - self.revert_to_checkpoint(lines[0]) - - # Copied methods of a scene are handled specially - # A bit hacky, yes, but convenient - method_pattern = r"^def\s+([a-zA-Z_]\w*)\s*\(self.*\):" - method_names = re.findall(method_pattern ,lines[0].strip()) - if method_names: - method_name = method_names[0] - indent = " " * lines[0].index(lines[0].strip()) - pasted = "\n".join([ - # Remove self from function signature - re.sub(r"self(,\s*)?", "", lines[0]), - *lines[1:], - # Attach to scene via self.func_name = func_name - f"{indent}self.{method_name} = {method_name}" - ]) - # Keep track of skipping and progress bar status self.skip_animations = skip @@ -801,7 +693,7 @@ class Scene(object): self.camera.use_window_fbo(False) self.file_writer.begin_insert() - self.shell.run_cell(pasted) + self.checkpoint_manager.checkpoint_paste(self) if record: self.file_writer.end_insert() @@ -810,22 +702,8 @@ class Scene(object): self.stop_skipping() self.show_animation_progress = prev_progress - def checkpoint(self, key: str): - self.checkpoint_states[key] = self.get_state() - - def revert_to_checkpoint(self, key: str): - if key not in self.checkpoint_states: - log.error(f"No checkpoint at {key}") - return - all_keys = list(self.checkpoint_states.keys()) - index = all_keys.index(key) - for later_key in all_keys[index + 1:]: - self.checkpoint_states.pop(later_key) - - self.restore_state(self.checkpoint_states[key]) - def clear_checkpoints(self): - self.checkpoint_states = dict() + self.checkpoint_manager.clear_checkpoints() def save_mobject_to_file(self, mobject: Mobject, file_path: str | None = None) -> None: if file_path is None: diff --git a/manimlib/scene/scene_embed.py b/manimlib/scene/scene_embed.py new file mode 100644 index 00000000..43f4be5f --- /dev/null +++ b/manimlib/scene/scene_embed.py @@ -0,0 +1,178 @@ +import inspect +import pyperclip +import re + +from IPython.terminal import pt_inputhooks +from IPython.terminal.embed import InteractiveShellEmbed + +from manimlib.animation.fading import VFadeInThenOut +from manimlib.constants import RED +from manimlib.mobject.mobject import Mobject +from manimlib.mobject.frame import FullScreenRectangle +from manimlib.module_loader import ModuleLoader + + +def interactive_scene_embed(scene): + if not scene.window: + # Embed is only relevant for interactive development with a Window + return + scene.stop_skipping() + scene.update_frame(force_draw=True) + scene.save_state() + + shell = get_ipython_shell_for_embedded_scene(scene) + scene.shell = shell # It would be better not to add attributes to scene here + enable_gui(shell, scene) + ensure_frame_update_post_cell(shell, scene) + ensure_flash_on_error(shell, scene) + + # Launch shell + shell() + + +def get_ipython_shell_for_embedded_scene(scene): + """ + Create embedded IPython terminal configured to have access to + the local namespace of the caller + """ + # Triple back should take us to the context in a user's scene definition + # which is calling "self.embed" + caller_frame = inspect.currentframe().f_back.f_back.f_back + module = ModuleLoader.get_module(caller_frame.f_globals["__file__"]) + shell = InteractiveShellEmbed( + user_module=module, + display_banner=False, + xmode=scene.embed_exception_mode + ) + + # Update the module's namespace to match include local variables + module.__dict__.update(caller_frame.f_locals) + module.__dict__.update(get_shortcuts(scene)) + + return shell + + +def get_shortcuts(scene): + """ + A few custom shortcuts useful to have in the interactive shell namespace + """ + return dict( + play=scene.play, + wait=scene.wait, + add=scene.add, + remove=scene.remove, + clear=scene.clear, + focus=scene.focus, + save_state=scene.save_state, + reload=scene.reload, + undo=scene.undo, + redo=scene.redo, + i2g=scene.i2g, + i2m=scene.i2m, + checkpoint_paste=scene.checkpoint_paste, + ) + + +def enable_gui(shell, scene): + """Enables gui interactions during the embed""" + def inputhook(context): + while not context.input_is_ready(): + if not scene.is_window_closing(): + scene.update_frame(dt=0) + if scene.is_window_closing(): + shell.ask_exit() + + pt_inputhooks.register("manim", inputhook) + shell.enable_gui("manim") + + +def ensure_frame_update_post_cell(shell, scene): + """Ensure the scene updates its frame after each ipython cell""" + def post_cell_func(*args, **kwargs): + if not scene.is_window_closing(): + scene.update_frame(dt=0, force_draw=True) + + shell.events.register("post_run_cell", post_cell_func) + + +def ensure_flash_on_error(shell, scene): + """Flash border, and potentially play sound, on exceptions""" + def custom_exc(shell, etype, evalue, tb, tb_offset=None): + # Show the error don't just swallow it + shell.showtraceback((etype, evalue, tb), tb_offset=tb_offset) + if scene.embed_error_sound: + os.system("printf '\a'") + rect = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0) + rect.fix_in_frame() + scene.play(VFadeInThenOut(rect, run_time=0.5)) + + shell.set_custom_exc((Exception,), custom_exc) + + +class CheckpointManager: + checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict() + + def checkpoint_paste(self, scene): + """ + Used during interactive development to run (or re-run) + a block of scene code. + + If the copied selection starts with a comment, this will + revert to the state of the scene the first time this function + was called on a block of code starting with that comment. + """ + shell = get_ipython() + if shell is None: + return + + code_string = pyperclip.paste() + + checkpoint_key = self.get_leading_comment(code_string) + self.handle_checkpoint_key(scene, checkpoint_key) + code_string = self.handle_method_definitions(code_string) + + shell.run_cell(code_string) + + @staticmethod + def handle_method_definitions(code_string: str): + lines = code_string.split("\n") + # Copied methods of a scene are handled specially + # A bit hacky, yes, but convenient + method_pattern = r"^def\s+([a-zA-Z_]\w*)\s*\(self.*\):" + method_names = re.findall(method_pattern, lines[0].strip()) + if method_names: + method_name = method_names[0] + indent = " " * lines[0].index(lines[0].strip()) + return "\n".join([ + # Remove self from function signature + re.sub(r"self(,\s*)?", "", lines[0]), + *lines[1:], + # Attach to scene via self.func_name = func_name + f"{indent}self.{method_name} = {method_name}" + ]) + return code_string + + @staticmethod + def get_leading_comment(code_string: str): + leading_line = code_string.partition("\n")[0].lstrip() + if leading_line.startswith("#"): + return leading_line + return None + + def handle_checkpoint_key(self, scene, key: str): + if key is None: + return + elif key in self.checkpoint_states: + # Revert to checkpoint + scene.restore_state(self.checkpoint_states[key]) + + # Clear out any saved states that show up later + all_keys = list(self.checkpoint_states.keys()) + index = all_keys.index(key) + for later_key in all_keys[index + 1:]: + self.checkpoint_states.pop(later_key) + else: + self.checkpoint_states[key] = scene.get_state() + + def clear_checkpoints(self): + self.checkpoint_states = dict() From 40b5c7c1c126f33e346514fd6616b8b24cb05c10 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 13:56:33 -0600 Subject: [PATCH 47/56] Slightly clean up interactive_scene_embed --- manimlib/scene/scene.py | 7 ++++++- manimlib/scene/scene_embed.py | 5 ----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index df1a4a38..775772d6 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -204,6 +204,9 @@ class Scene(object): close_scene_on_exit: bool = True, show_animation_progress: bool = False, ) -> None: + if not self.window: + # Embed is only relevant for interactive development with a Window + return self.show_animation_progress = show_animation_progress interactive_scene_embed(self) @@ -887,7 +890,9 @@ class Scene(object): this means. """ reload_manager.set_new_start_at_line(start_at_line) - self.shell.run_line_magic("exit_raise", "") + shell = get_ipython() + if shell: + shell.run_line_magic("exit_raise", "") def focus(self) -> None: """ diff --git a/manimlib/scene/scene_embed.py b/manimlib/scene/scene_embed.py index 43f4be5f..60273011 100644 --- a/manimlib/scene/scene_embed.py +++ b/manimlib/scene/scene_embed.py @@ -13,15 +13,10 @@ from manimlib.module_loader import ModuleLoader def interactive_scene_embed(scene): - if not scene.window: - # Embed is only relevant for interactive development with a Window - return scene.stop_skipping() scene.update_frame(force_draw=True) - scene.save_state() shell = get_ipython_shell_for_embedded_scene(scene) - scene.shell = shell # It would be better not to add attributes to scene here enable_gui(shell, scene) ensure_frame_update_post_cell(shell, scene) ensure_flash_on_error(shell, scene) From cb3e115a6c7780de1a0f4e559bc71a1ba9d1ca24 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 14:01:34 -0600 Subject: [PATCH 48/56] Minor cleaning --- manimlib/scene/scene_embed.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/manimlib/scene/scene_embed.py b/manimlib/scene/scene_embed.py index 60273011..e962f8c4 100644 --- a/manimlib/scene/scene_embed.py +++ b/manimlib/scene/scene_embed.py @@ -33,19 +33,18 @@ def get_ipython_shell_for_embedded_scene(scene): # Triple back should take us to the context in a user's scene definition # which is calling "self.embed" caller_frame = inspect.currentframe().f_back.f_back.f_back + + # Update the module's namespace to include local variables module = ModuleLoader.get_module(caller_frame.f_globals["__file__"]) - shell = InteractiveShellEmbed( + module.__dict__.update(caller_frame.f_locals) + module.__dict__.update(get_shortcuts(scene)) + + return InteractiveShellEmbed( user_module=module, display_banner=False, xmode=scene.embed_exception_mode ) - # Update the module's namespace to match include local variables - module.__dict__.update(caller_frame.f_locals) - module.__dict__.update(get_shortcuts(scene)) - - return shell - def get_shortcuts(scene): """ @@ -130,9 +129,9 @@ class CheckpointManager: @staticmethod def handle_method_definitions(code_string: str): - lines = code_string.split("\n") # Copied methods of a scene are handled specially # A bit hacky, yes, but convenient + lines = code_string.split("\n") method_pattern = r"^def\s+([a-zA-Z_]\w*)\s*\(self.*\):" method_names = re.findall(method_pattern, lines[0].strip()) if method_names: From 5b315d5c7041d86bf01d4aeb151fc2f9fb97662c Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 14:02:22 -0600 Subject: [PATCH 49/56] Get rid of the (hacky) solution to redefining Scene methods, since reload handles it better --- manimlib/scene/scene_embed.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/manimlib/scene/scene_embed.py b/manimlib/scene/scene_embed.py index e962f8c4..ff507dfd 100644 --- a/manimlib/scene/scene_embed.py +++ b/manimlib/scene/scene_embed.py @@ -123,29 +123,8 @@ class CheckpointManager: checkpoint_key = self.get_leading_comment(code_string) self.handle_checkpoint_key(scene, checkpoint_key) - code_string = self.handle_method_definitions(code_string) - shell.run_cell(code_string) - @staticmethod - def handle_method_definitions(code_string: str): - # Copied methods of a scene are handled specially - # A bit hacky, yes, but convenient - lines = code_string.split("\n") - method_pattern = r"^def\s+([a-zA-Z_]\w*)\s*\(self.*\):" - method_names = re.findall(method_pattern, lines[0].strip()) - if method_names: - method_name = method_names[0] - indent = " " * lines[0].index(lines[0].strip()) - return "\n".join([ - # Remove self from function signature - re.sub(r"self(,\s*)?", "", lines[0]), - *lines[1:], - # Attach to scene via self.func_name = func_name - f"{indent}self.{method_name} = {method_name}" - ]) - return code_string - @staticmethod def get_leading_comment(code_string: str): leading_line = code_string.partition("\n")[0].lstrip() From bf81d94362691a37052107fd65a8de30026d668e Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 15:54:16 -0600 Subject: [PATCH 50/56] Don't make reload_manager a global variable --- manimlib/__main__.py | 3 ++- manimlib/reload_manager.py | 5 ++--- manimlib/scene/scene.py | 9 ++++++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/manimlib/__main__.py b/manimlib/__main__.py index aa283b80..a0bfb153 100644 --- a/manimlib/__main__.py +++ b/manimlib/__main__.py @@ -3,7 +3,7 @@ from manimlib import __version__ import manimlib.config import manimlib.logger import manimlib.utils.init_config -from manimlib.reload_manager import reload_manager +from manimlib.reload_manager import ReloadManager def main(): @@ -22,6 +22,7 @@ def main(): manimlib.utils.init_config.init_customization() return + reload_manager = ReloadManager() reload_manager.args = args reload_manager.run() diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index 8b8db095..a01f7b08 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -65,6 +65,8 @@ class ReloadManager: # Args to Config scene_config = manimlib.config.get_scene_config(self.args) + scene_config.update(reload_manager=self) + run_config = manimlib.config.get_run_config(self.args) run_config.update(is_reload=self.is_reload) @@ -88,6 +90,3 @@ class ReloadManager: f"at line \033[96m{self.args.embed}\033[0m" ])) scene.run() - - -reload_manager = ReloadManager() diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 775772d6..c30176d4 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -20,7 +20,6 @@ from manimlib.constants import DEFAULT_WAIT_TIME from manimlib.event_handler import EVENT_DISPATCHER from manimlib.event_handler.event_type import EventType from manimlib.logger import log -from manimlib.reload_manager import reload_manager from manimlib.mobject.mobject import _AnimationBuilder from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Mobject @@ -45,6 +44,7 @@ if TYPE_CHECKING: from PIL.Image import Image + from manimlib.reload_manager import ReloadManager from manimlib.animation.animation import Animation @@ -76,6 +76,7 @@ class Scene(object): end_at_animation_number: int | None = None, leave_progress_bars: bool = False, window: Optional[Window] = None, + reload_manager: Optional[ReloadManager] = None, presenter_mode: bool = False, show_animation_progress: bool = False, embed_exception_mode: str = "", @@ -90,6 +91,7 @@ class Scene(object): self.show_animation_progress = show_animation_progress self.embed_exception_mode = embed_exception_mode self.embed_error_sound = embed_error_sound + self.reload_manager = reload_manager self.camera_config = {**self.default_camera_config, **camera_config} self.file_writer_config = {**self.default_file_writer_config, **file_writer_config} @@ -877,19 +879,20 @@ class Scene(object): If `start_at_line` is provided, the scene will be reloaded at that line number. This corresponds to the `linemarker` param of the - `config.get_module_with_inserted_embed_line()` method. + `extract_scene.insert_embed_line_to_module()` method. Before reload, the scene is cleared and the entire state is reset, such that we can start from a clean slate. This is taken care of by the ReloadManager, which will catch the error raised by the `exit_raise` magic command that we invoke here. + Note that we cannot define a custom exception class for this error, since the IPython kernel will swallow any exception. While we can catch such an exception in our custom exception handler registered with the `set_custom_exc` method, we cannot break out of the IPython shell by this means. """ - reload_manager.set_new_start_at_line(start_at_line) + self.reload_manager.set_new_start_at_line(start_at_line) shell = get_ipython() if shell: shell.run_line_magic("exit_raise", "") From 6d0b23f914869851e734c89aec0969eff3bc2dd1 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 16:14:27 -0600 Subject: [PATCH 51/56] Slightly simplify ReloadManager --- manimlib/__main__.py | 3 +-- manimlib/reload_manager.py | 29 ++++++++++++++++++----------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/manimlib/__main__.py b/manimlib/__main__.py index a0bfb153..b5877f8e 100644 --- a/manimlib/__main__.py +++ b/manimlib/__main__.py @@ -22,8 +22,7 @@ def main(): manimlib.utils.init_config.init_customization() return - reload_manager = ReloadManager() - reload_manager.args = args + reload_manager = ReloadManager(args) reload_manager.run() diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index a01f7b08..7e699a9c 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -1,9 +1,20 @@ +from __future__ import annotations + from typing import Any from IPython.terminal.embed import KillEmbedded + +import manimlib.config +import manimlib.extract_scene + from manimlib.window import Window +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from argparse import Namespace + + class ReloadManager: """ Manages the loading and running of scenes and is called directly from the @@ -14,7 +25,6 @@ class ReloadManager: command in the IPython shell. """ - args: Any = None scenes: list[Any] = [] window = None @@ -23,6 +33,9 @@ class ReloadManager: is_reload = False + def __init__(self, cli_args: Namespace): + self.args = cli_args + def set_new_start_at_line(self, start_at_line): """ Sets/Updates the line number to load the scene from when reloading. @@ -36,7 +49,7 @@ class ReloadManager: while True: try: # blocking call since a scene will init an IPython shell() - self.retrieve_scenes_and_run(self.start_at_line) + self.retrieve_scenes_and_run() return except KillEmbedded: # Requested via the `exit_raise` IPython runline magic @@ -50,18 +63,12 @@ class ReloadManager: except KeyboardInterrupt: break - def retrieve_scenes_and_run(self, overwrite_start_at_line: int | None = None): + def retrieve_scenes_and_run(self): """ Creates a new configuration based on the CLI args and runs the scenes. """ - import manimlib.config - import manimlib.extract_scene - - # Args - if self.args is None: - raise RuntimeError("Fatal error: No args were passed to the ReloadManager") - if overwrite_start_at_line is not None: - self.args.embed = str(overwrite_start_at_line) + if self.start_at_line is not None: + self.args.embed = str(self.start_at_line) # Args to Config scene_config = manimlib.config.get_scene_config(self.args) From 7a69807ce63cca75e3deb8bb19ed385f759a7de9 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 16:24:50 -0600 Subject: [PATCH 52/56] Remove mobject.save_to_file This simply didn't work, and had no resilience to changes to the library. For cases where this might be useful, it's likely much better deliberately save specific data which is time-consuming to generate on the fly. --- manimlib/mobject/mobject.py | 15 ------------- manimlib/scene/interactive_scene.py | 8 ------- manimlib/scene/scene.py | 15 ------------- manimlib/scene/scene_file_writer.py | 33 ----------------------------- 4 files changed, 71 deletions(-) diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index b95acdbb..63089e43 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -715,21 +715,6 @@ class Mobject(object): self.become(self.saved_state) return self - def save_to_file(self, file_path: str) -> Self: - with open(file_path, "wb") as fp: - fp.write(self.serialize()) - log.info(f"Saved mobject to {file_path}") - return self - - @staticmethod - def load(file_path) -> Mobject: - if not os.path.exists(file_path): - log.error(f"No file found at {file_path}") - sys.exit(2) - with open(file_path, "rb") as fp: - mobject = pickle.load(fp) - return mobject - def become(self, mobject: Mobject, match_updaters=False) -> Self: """ Edit all data and submobjects to be idential diff --git a/manimlib/scene/interactive_scene.py b/manimlib/scene/interactive_scene.py index eb54569a..e1a53ee0 100644 --- a/manimlib/scene/interactive_scene.py +++ b/manimlib/scene/interactive_scene.py @@ -460,12 +460,6 @@ class InteractiveScene(Scene): nudge *= 10 self.selection.shift(nudge * vect) - def save_selection_to_file(self): - if len(self.selection) == 1: - self.save_mobject_to_file(self.selection[0]) - else: - self.save_mobject_to_file(self.selection) - # Key actions def on_key_press(self, symbol: int, modifiers: int) -> None: super().on_key_press(symbol, modifiers) @@ -503,8 +497,6 @@ class InteractiveScene(Scene): self.ungroup_selection() elif char == "t" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)): self.toggle_selection_mode() - elif char == "s" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)): - self.save_selection_to_file() elif char == "d" and (modifiers & PygletWindowKeys.MOD_SHIFT): self.copy_frame_positioning() elif char == "c" and (modifiers & PygletWindowKeys.MOD_SHIFT): diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index c30176d4..bd1b7be7 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -710,21 +710,6 @@ class Scene(object): def clear_checkpoints(self): self.checkpoint_manager.clear_checkpoints() - def save_mobject_to_file(self, mobject: Mobject, file_path: str | None = None) -> None: - if file_path is None: - file_path = self.file_writer.get_saved_mobject_path(mobject) - if file_path is None: - return - mobject.save_to_file(file_path) - - def load_mobject(self, file_name): - if os.path.exists(file_name): - path = file_name - else: - directory = self.file_writer.get_saved_mobject_directory() - path = os.path.join(directory, file_name) - return Mobject.load(path) - def is_window_closing(self): return self.window and (self.window.is_closing or self.quit_interaction) diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py index 5d7b0d2d..f98d68c8 100644 --- a/manimlib/scene/scene_file_writer.py +++ b/manimlib/scene/scene_file_writer.py @@ -146,39 +146,6 @@ class SceneFileWriter(object): def get_movie_file_path(self) -> str: return self.movie_file_path - def get_saved_mobject_directory(self) -> str: - return guarantee_existence(self.saved_mobject_directory) - - def get_saved_mobject_path(self, mobject: Mobject) -> str | None: - directory = self.get_saved_mobject_directory() - files = os.listdir(directory) - default_name = str(mobject) + "_0.mob" - index = 0 - while default_name in files: - default_name = default_name.replace(str(index), str(index + 1)) - index += 1 - if platform.system() == 'Darwin': - cmds = [ - "osascript", "-e", - f""" - set chosenfile to (choose file name default name "{default_name}" default location "{directory}") - POSIX path of chosenfile - """, - ] - process = sp.Popen(cmds, stdout=sp.PIPE) - file_path = process.stdout.read().decode("utf-8").split("\n")[0] - if not file_path: - return - else: - user_name = input(f"Enter mobject file name (default is {default_name}): ") - file_path = os.path.join(directory, user_name or default_name) - if os.path.exists(file_path) or os.path.exists(file_path + ".mob"): - if input(f"{file_path} already exists. Overwrite (y/n)? ") != "y": - return - if not file_path.endswith(".mob"): - file_path = file_path + ".mob" - return file_path - # Sound def init_audio(self) -> None: self.includes_sound: bool = False From 88bae476ce3dc9d4cb8ca4349ace922d7a873311 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 16:25:18 -0600 Subject: [PATCH 53/56] Don't print filename that is being reloaded --- manimlib/reload_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index 7e699a9c..a14cff7f 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -93,7 +93,6 @@ class ReloadManager: print(" ".join([ "Reloading interactive session for", f"\033[96m{self.args.scene_names[0]}\033[0m", - f"in \033[96m{self.args.file}\033[0m", f"at line \033[96m{self.args.embed}\033[0m" ])) scene.run() From dd508b8cfc5452245f117d18b483be770b423823 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 16:43:08 -0600 Subject: [PATCH 54/56] No need to track ReloadManager.start_at_line --- manimlib/reload_manager.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index a14cff7f..c73b1672 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -27,10 +27,6 @@ class ReloadManager: scenes: list[Any] = [] window = None - - # The line number to load the scene from when reloading - start_at_line = None - is_reload = False def __init__(self, cli_args: Namespace): @@ -40,7 +36,7 @@ class ReloadManager: """ Sets/Updates the line number to load the scene from when reloading. """ - self.start_at_line = start_at_line + self.args.embed = str(start_at_line) def run(self): """ @@ -58,18 +54,22 @@ class ReloadManager: scene.tear_down() self.scenes = [] - self.is_reload = True - + self.note_reload() except KeyboardInterrupt: break + def note_reload(self): + self.is_reload = True + print(" ".join([ + "Reloading interactive session for", + f"\033[96m{self.args.scene_names[0]}\033[0m", + f"at line \033[96m{self.args.embed}\033[0m" + ])) + def retrieve_scenes_and_run(self): """ Creates a new configuration based on the CLI args and runs the scenes. """ - if self.start_at_line is not None: - self.args.embed = str(self.start_at_line) - # Args to Config scene_config = manimlib.config.get_scene_config(self.args) scene_config.update(reload_manager=self) @@ -86,13 +86,6 @@ class ReloadManager: self.scenes = manimlib.extract_scene.main(scene_config, run_config) if len(self.scenes) == 0: print("No scenes found to run") - return for scene in self.scenes: - if self.args.embed and self.is_reload: - print(" ".join([ - "Reloading interactive session for", - f"\033[96m{self.args.scene_names[0]}\033[0m", - f"at line \033[96m{self.args.embed}\033[0m" - ])) scene.run() From 8706ba1589a7a42968d668ff6fedd6b10c8204cd Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 16:46:13 -0600 Subject: [PATCH 55/56] No real need to track ReloadManager.scenes This was to be able to loop through an tear them down, but tear down is primarily about ending any file writing, and potentially cleaning up a window, which for the sake of reusing a window we don't want to do anyway. --- manimlib/reload_manager.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index c73b1672..c0cff3e7 100644 --- a/manimlib/reload_manager.py +++ b/manimlib/reload_manager.py @@ -25,7 +25,6 @@ class ReloadManager: command in the IPython shell. """ - scenes: list[Any] = [] window = None is_reload = False @@ -50,10 +49,6 @@ class ReloadManager: except KillEmbedded: # Requested via the `exit_raise` IPython runline magic # by means of our scene.reload() command - for scene in self.scenes: - scene.tear_down() - - self.scenes = [] self.note_reload() except KeyboardInterrupt: break @@ -83,9 +78,9 @@ class ReloadManager: scene_config.update(window=self.window) # Scenes - self.scenes = manimlib.extract_scene.main(scene_config, run_config) - if len(self.scenes) == 0: + scenes = manimlib.extract_scene.main(scene_config, run_config) + if len(scenes) == 0: print("No scenes found to run") - for scene in self.scenes: + for scene in scenes: scene.run() From 950ac31b9b2a340ab32c039c05d07e318598d485 Mon Sep 17 00:00:00 2001 From: Grant Sanderson Date: Mon, 9 Dec 2024 16:57:55 -0600 Subject: [PATCH 56/56] Replace IGNORE_MANIMLIB_MODULES constant with a piece of global configuration --- manimlib/default_config.yml | 1 + manimlib/module_loader.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 4c9f0c3a..6f4224c5 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -58,3 +58,4 @@ camera_resolutions: fps: 30 embed_exception_mode: "Verbose" embed_error_sound: False +ignore_manimlib_modules_on_reload: True diff --git a/manimlib/module_loader.py b/manimlib/module_loader.py index e43f8973..50fcf999 100644 --- a/manimlib/module_loader.py +++ b/manimlib/module_loader.py @@ -6,12 +6,11 @@ import os import sys import sysconfig +from manimlib.config import get_global_config from manimlib.logger import log Module = importlib.util.types.ModuleType -IGNORE_MANIMLIB_MODULES = True - class ModuleLoader: """ @@ -143,7 +142,8 @@ class ModuleLoader: Only user-defined modules are reloaded, see `is_user_defined_module()`. """ - if IGNORE_MANIMLIB_MODULES and module.__name__.startswith("manimlib"): + ignore_manimlib_modules = get_global_config()["ignore_manimlib_modules_on_reload"] + if ignore_manimlib_modules and module.__name__.startswith("manimlib"): return if not hasattr(module, "__dict__"):