Cleaner local caching of Tex/Text data, and partially cleaned up configuration (#2259)

* Remove print("Reloading...")

* Change where exception mode is set, to be quieter

* Add default fallback monitor for when no monitors are detected

* 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

* Add caching functionality, and have Tex and Text both use it for saved svg strings

* Clean up tex_file_writing

* Get rid of get_tex_dir and get_text_dir

* Allow for a configurable cache location

* Make caching on disk a decorator, and update implementations for Tex and Text mobjects

* Remove stray prints

* 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.

* Make the default size for hash_string an option

* Remove utils/customization.py

* Remove stray prints

* 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.

* Factor out configuration to be passed into a scene vs. that used to run a scene

* Use newer extract_scene.main interface

* Add clarifying message to note what exactly is being reloaded

* Minor clean up

* Minor clean up

* If it's worth caching to disk, then might as well do so in memory too during development

* No longer any need for custom hash_seeds in Tex and Text

* Remove display_during_execution

* Get rid of (no longer used) mobject_data directory reference

* Remove get_downloads_dir reference from register_font

* Update where downloads go

* Easier use of subdirectories in configuration

* Add new pip requirements
This commit is contained in:
Grant Sanderson 2024-12-05 16:51:14 -06:00 committed by GitHub
parent 5a70d67b98
commit 94f6f0aa96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 417 additions and 462 deletions

View file

@ -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 *

View file

@ -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,
}

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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])

34
manimlib/utils/cache.py Normal file
View file

@ -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()

View file

@ -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

View file

@ -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")

View file

@ -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)

View file

@ -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
)

View file

@ -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]

View file

@ -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):

View file

@ -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

View file

@ -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