diff --git a/manimlib/__init__.py b/manimlib/__init__.py index 8467f980..6df40dc8 100644 --- a/manimlib/__init__.py +++ b/manimlib/__init__.py @@ -61,9 +61,9 @@ 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 * from manimlib.utils.debug import * from manimlib.utils.directories import * from manimlib.utils.file_ops import * diff --git a/manimlib/config.py b/manimlib/config.py index 7065056e..3e299459 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 @@ -17,9 +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 -__config_file__ = "custom_config.yml" +# 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(): @@ -300,69 +305,30 @@ 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") + 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 {}, + ) - if os.path.exists(global_defaults_file): - with open(global_defaults_file, "r") as file: - custom_config = yaml.safe_load(file) + # Set the subdirectories + base = config['directories']['base'] + for key, subdir in config['directories']['subdirs'].items(): + config['directories'][key] = os.path.join(base, subdir) - 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 config def get_file_ext(args: Namespace) -> str: @@ -435,7 +401,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"] @@ -446,8 +413,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 @@ -475,7 +447,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, }) @@ -496,21 +468,19 @@ def get_camera_config(args: Namespace, custom_config: dict) -> dict: return camera_config -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) +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, 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, - "write_all": args.write_all, "skip_animations": args.skip_animations, "start_at_animation_number": start, "end_at_animation_number": end, @@ -518,26 +488,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": 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"], } -def get_frame_height(): - return 8.0 - - -def get_aspect_ratio(): - cam_config = get_camera_config(parse_cli(), get_custom_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()) - return cam_config['pixel_width'] - - -def get_default_pixel_height(): - cam_config = get_camera_config(parse_cli(), get_custom_config()) - return cam_config['pixel_height'] +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/constants.py b/manimlib/constants.py index 1eb93e08..7850796c 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,20 @@ 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, 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 = get_aspect_ratio() -FRAME_HEIGHT: float = get_frame_height() +ASPECT_RATIO: float = CAMERA_CONFIG['pixel_width'] / CAMERA_CONFIG['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 = 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 diff --git a/manimlib/default_config.yml b/manimlib/default_config.yml index 637175fb..4c9f0c3a 100644 --- a/manimlib/default_config.yml +++ b/manimlib/default_config.yml @@ -3,19 +3,28 @@ 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: "" - # 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: "" + # 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, + # 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/extract_scene.py b/manimlib/extract_scene.py index 101c5725..15dd3e13 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() @@ -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"] + 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): 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,10 @@ def get_scene_classes_from_module(module): ] -def main(config): - module = config["module"] - scene_config = get_scene_config(config) - if module is None: +def main(scene_config, run_config): + 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, 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) 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/old_tex_mobject.py b/manimlib/mobject/svg/old_tex_mobject.py index 61d4dd62..7adc216e 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 latex_to_svg from typing import TYPE_CHECKING @@ -76,12 +76,8 @@ 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 - ) - return file_path + def get_svg_string_by_content(self, content: str) -> str: + 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/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 e0016bee..e39d6774 100644 --- a/manimlib/mobject/svg/svg_mobject.py +++ b/manimlib/mobject/svg/svg_mobject.py @@ -5,6 +5,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 @@ -39,6 +40,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, @@ -63,11 +65,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() @@ -97,7 +107,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) @@ -111,11 +121,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 @@ -127,10 +137,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 b5f598e4..5517fb72 100644 --- a/manimlib/mobject/svg/tex_mobject.py +++ b/manimlib/mobject/svg/tex_mobject.py @@ -1,14 +1,16 @@ 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 from manimlib.mobject.types.vectorized_mobject import VMobject 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 latex_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 @@ -65,27 +67,8 @@ 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_file_path_by_content(self, content: str) -> str: - return 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 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 a16d322f..3f36a7c2 100644 --- a/manimlib/mobject/svg/text_mobject.py +++ b/manimlib/mobject/svg/text_mobject.py @@ -4,21 +4,23 @@ from contextlib import contextmanager import os from pathlib import Path import re +import tempfile +import hashlib +from functools import lru_cache import manimpango 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 from manimlib.mobject.svg.string_mobject import StringMobject -from manimlib.utils.customization import get_customization +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.directories import get_text_dir from manimlib.utils.simple_functions import hash_string from typing import TYPE_CHECKING @@ -49,6 +51,58 @@ class _Alignment: self.value = _Alignment.VAL_DICT[s.upper()] +@lru_cache(maxsize=128) +@cache_on_disk +def markup_to_svg( + markup_str: str, + justify: bool = False, + indent: float = 0, + alignment: str = "CENTER", + 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 = { @@ -102,13 +156,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 @@ -141,88 +196,14 @@ 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_file_path_by_content(self, content: str) -> str: - hash_content = str(( + def get_svg_string_by_content(self, content: str) -> str: + self.content = content + return markup_to_svg( content, - self.justify, - self.indent, - 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 - - def markup_to_svg(self, markup_str: str, file_name: 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 - - 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 - ) - - @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}" + alignment=self.alignment, + line_width=self.line_width ) # Toolkits @@ -511,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 diff --git a/manimlib/reload_manager.py b/manimlib/reload_manager.py index 3d8e4f17..3d58c97a 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() @@ -61,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 @@ -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() 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() diff --git a/manimlib/shader_wrapper.py b/manimlib/shader_wrapper.py index 259755e1..6d4cc4ca 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.shaders import get_shader_code_from_file from manimlib.utils.shaders import get_shader_program from manimlib.utils.shaders import image_path_to_texture @@ -410,7 +410,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]) diff --git a/manimlib/utils/cache.py b/manimlib/utils/cache.py new file mode 100644 index 00000000..e6454057 --- /dev/null +++ b/manimlib/utils/cache.py @@ -0,0 +1,34 @@ +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 cache_on_disk(func: Callable[..., T]) -> Callable[..., T]: + @wraps(func) + def wrapper(*args, **kwargs): + key = hash_string(f"{func.__name__}{args}{kwargs}") + value = _cache.get(key) + if value is None: + value = func(*args, **kwargs) + _cache.set(key, value) + return value + return wrapper + + +def clear_cache(): + _cache.clear() diff --git a/manimlib/utils/customization.py b/manimlib/utils/customization.py deleted file mode 100644 index 94923b43..00000000 --- a/manimlib/utils/customization.py +++ /dev/null @@ -1,24 +0,0 @@ -import os -import tempfile - -from manimlib.config import get_custom_config -from manimlib.config import get_manim_dir - - -CUSTOMIZATION = {} - - -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() - - # Assumes all shaders are written into manimlib/shaders - directories["shaders"] = os.path.join( - get_manim_dir(), "manimlib", "shaders" - ) - return CUSTOMIZATION diff --git a/manimlib/utils/directories.py b/manimlib/utils/directories.py index daf1714c..e4a2bc03 100644 --- a/manimlib/utils/directories.py +++ b/manimlib/utils/directories.py @@ -1,33 +1,29 @@ from __future__ import annotations import os +import tempfile +import appdirs -from manimlib.utils.customization import get_customization + +from manimlib.config import get_global_config +from manimlib.config import get_manim_dir 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: + return get_directories()["cache"] or appdirs.user_cache_dir("manim") 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")) + return get_directories()["temporary_storage"] or tempfile.gettempdir() 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: @@ -47,4 +43,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/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) 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 ) 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] diff --git a/manimlib/utils/tex_file_writing.py b/manimlib/utils/tex_file_writing.py index 6538adc9..1f2ea4fe 100644 --- a/manimlib/utils/tex_file_writing.py +++ b/manimlib/utils/tex_file_writing.py @@ -1,14 +1,18 @@ from __future__ import annotations -from contextlib import contextmanager import os import re import yaml +import subprocess +from functools import lru_cache -from manimlib.config import get_custom_config +from pathlib import Path +import tempfile + +from manimlib.utils.cache import cache_on_disk +from manimlib.config import get_global_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 @@ -17,9 +21,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( @@ -41,7 +44,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, @@ -51,22 +54,8 @@ 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 -) -> 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}", @@ -74,17 +63,43 @@ 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 +@lru_cache(maxsize=128) +@cache_on_disk +def latex_to_svg( + latex: str, + template: str = "", + additional_preamble: str = "", + short_tex: str = "", + show_message_during_execution: bool = True, +) -> 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 + """ + 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) + + 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,72 +107,65 @@ 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 - ))) + # Return SVG string + result = process.stdout.decode('utf-8') - # Cleanup superfluous documents - for ext in (".tex", dvi_ext, ".log", ".aux"): - try: - os.remove(root + ext) - except FileNotFoundError: - pass + if show_message_during_execution: + print(" " * len(message), end="\r") - -# 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") + return result class LatexError(Exception): diff --git a/manimlib/window.py b/manimlib/window.py index d7a19bfa..4c7da0f5 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 @@ -86,9 +86,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 diff --git a/requirements.txt b/requirements.txt index 161fbd98..b0ebbb36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ +appdirs colour +diskcache ipython>=8.18.0 isosurfaces fontTools @@ -20,6 +22,7 @@ screeninfo skia-pathops svgelements>=1.8.1 sympy +tempfile tqdm typing-extensions; python_version < "3.11" validators