diff --git a/docs/source/manim_directive.py b/docs/source/manim_directive.py index 77ff580579..11383f7cc5 100644 --- a/docs/source/manim_directive.py +++ b/docs/source/manim_directive.py @@ -207,18 +207,18 @@ def run(self): video_dir = os.path.join(media_dir, "videos") output_file = f"{clsname}-{classnamedict[clsname]}" - file_writer_config_code = [ + config_code = [ f'config["frame_rate"] = {frame_rate}', f'config["pixel_height"] = {pixel_height}', f'config["pixel_width"] = {pixel_width}', - f'file_writer_config["media_dir"] = r"{media_dir}"', - f'file_writer_config["images_dir"] = r"{images_dir}"', - f'file_writer_config["tex_dir"] = r"{tex_dir}"', - f'file_writer_config["text_dir"] = r"{text_dir}"', - f'file_writer_config["video_dir"] = r"{video_dir}"', - f'file_writer_config["save_last_frame"] = {save_last_frame}', - f'file_writer_config["save_as_gif"] = {save_as_gif}', - f'file_writer_config["output_file"] = r"{output_file}"', + f'config["media_dir"] = r"{media_dir}"', + f'config["images_dir"] = r"{images_dir}"', + f'config["tex_dir"] = r"{tex_dir}"', + f'config["text_dir"] = r"{text_dir}"', + f'config["video_dir"] = r"{video_dir}"', + f'config["save_last_frame"] = {save_last_frame}', + f'config["save_as_gif"] = {save_as_gif}', + f'config["output_file"] = r"{output_file}"', ] user_code = self.content @@ -229,7 +229,7 @@ def run(self): code = [ "from manim import *", - *file_writer_config_code, + *config_code, *user_code, f"{clsname}().render()", ] diff --git a/docs/source/reference.rst b/docs/source/reference.rst index e75baee3b3..4d77002e8d 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -128,6 +128,6 @@ Other modules .. autosummary:: :toctree: reference - _config + config constants container diff --git a/manim/__main__.py b/manim/__main__.py index fd468d6fc5..0f13228f69 100644 --- a/manim/__main__.py +++ b/manim/__main__.py @@ -3,7 +3,7 @@ import sys import traceback -from manim import constants, logger, console, config, file_writer_config +from manim import constants, logger, console, config from manim import Scene from manim.utils.module_ops import ( get_module, @@ -12,38 +12,35 @@ ) from manim.utils.file_ops import open_file as open_media_file from manim.grpc.impl import frame_server_impl +from manim.config.utils import init_dirs from manim.config.main_utils import * def open_file_if_needed(file_writer): - if file_writer_config["verbosity"] != "DEBUG": + if config["verbosity"] != "DEBUG": curr_stdout = sys.stdout sys.stdout = open(os.devnull, "w") - open_file = any( - [file_writer_config["preview"], file_writer_config["show_in_file_browser"]] - ) + open_file = any([config["preview"], config["show_in_file_browser"]]) + if open_file: current_os = platform.system() file_paths = [] - if file_writer_config["save_last_frame"]: + if config["save_last_frame"]: file_paths.append(file_writer.get_image_file_path()) - if ( - file_writer_config["write_to_movie"] - and not file_writer_config["save_as_gif"] - ): + if config["write_to_movie"] and not config["save_as_gif"]: file_paths.append(file_writer.get_movie_file_path()) - if file_writer_config["save_as_gif"]: + if config["save_as_gif"]: file_paths.append(file_writer.gif_file_path) for file_path in file_paths: - if file_writer_config["show_in_file_browser"]: + if config["show_in_file_browser"]: open_media_file(file_path, True) - if file_writer_config["preview"]: + if config["preview"]: open_media_file(file_path, False) - if file_writer_config["verbosity"] != "DEBUG": + if config["verbosity"] != "DEBUG": sys.stdout.close() sys.stdout = curr_stdout @@ -69,13 +66,10 @@ def main(): # something_else_here() else: - update_config_with_cli(args) - init_dirs(file_writer_config) - - if file_writer_config["log_to_file"]: - set_file_logger() + config.digest_args(args) + init_dirs(config) - module = get_module(file_writer_config["input_file"]) + module = get_module(config["input_file"]) all_scene_classes = get_scene_classes_from_module(module) scene_classes_to_render = get_scenes_to_render(all_scene_classes) for SceneClass in scene_classes_to_render: diff --git a/manim/camera/camera.py b/manim/camera/camera.py index 6fb59497e9..84bbdc50e3 100644 --- a/manim/camera/camera.py +++ b/manim/camera/camera.py @@ -14,7 +14,7 @@ import cairo import numpy as np -from .. import logger, config, camera_config +from .. import logger, config from ..constants import * from ..mobject.types.image_mobject import AbstractImageMobject from ..mobject.mobject import Mobject @@ -53,8 +53,6 @@ class Camera(object): # Note: frame height and width will be resized to match # the pixel aspect ratio "frame_center": ORIGIN, - "background_color": BLACK, - "background_opacity": 1, # Points in vectorized mobjects with norm greater # than this value will be rescaled. "image_mode": "RGBA", @@ -91,6 +89,8 @@ def __init__(self, video_quality_config, background=None, **kwargs): "frame_height", "frame_width", "frame_rate", + "background_color", + "background_opacity", ]: setattr(self, attr, kwargs.get(attr, config[attr])) @@ -1039,9 +1039,7 @@ def adjusted_thickness(self, thickness): """ # TODO: This seems...unsystematic - big_sum = op.add( - camera_config["default_pixel_height"], camera_config["default_pixel_width"] - ) + big_sum = op.add(config["default_pixel_height"], config["default_pixel_width"]) this_sum = op.add(self.pixel_height, self.pixel_width) factor = fdiv(big_sum, this_sum) return 1 + (thickness - 1) / factor diff --git a/manim/config/__init__.py b/manim/config/__init__.py index 5895076cc5..491b14a89e 100644 --- a/manim/config/__init__.py +++ b/manim/config/__init__.py @@ -1,14 +1,14 @@ import logging from contextlib import contextmanager -from .utils import make_config_parser, make_logger, make_config, make_file_writer_config +from .logger import make_logger +from .utils import make_config_parser, ManimConfig, ManimFrame __all__ = [ "logger", "console", "config", - "file_writer_config", - "camera_config", + "frame", "tempconfig", ] @@ -22,9 +22,8 @@ logging.getLogger("PIL").setLevel(logging.INFO) logging.getLogger("matplotlib").setLevel(logging.INFO) -config = make_config(parser) -camera_config = config -file_writer_config = make_file_writer_config(parser, config) +config = ManimConfig(parser) +frame = ManimFrame(config) # This has to go here because it needs access to this module's config diff --git a/manim/config/default.cfg b/manim/config/default.cfg index cc4d7be3ac..64ee4b21a6 100644 --- a/manim/config/default.cfg +++ b/manim/config/default.cfg @@ -73,16 +73,13 @@ upto_animation_number = -1 media_dir = ./media # --log_dir (by default "/logs", that will be put inside the media dir) -log_dir = logs +log_dir = {media_dir}/logs -# # --video_dir -# video_dir = %(MEDIA_DIR)s/videos - -# # --tex_dir -# tex_dir = %(MEDIA_DIR)s/Tex - -# # --text_dir -# text_dir = %(MEDIA_DIR)s/texts +# the following do not have CLI arguments but depend on media_dir +video_dir = {media_dir}/videos +tex_dir = {media_dir}/Tex +text_dir = {media_dir}/texts +images_dir = {media_dir}/images # --use_js_renderer use_js_renderer = False @@ -112,45 +109,6 @@ disable_caching = False # --tex_template tex_template = -# These override the previous by using -t, --transparent -[transparent] -png_mode = RGBA -movie_file_extension = .mov -background_opacity = 0 - -# These override the previous by using -k, --four_k -[fourk_quality] -pixel_height = 2160 -pixel_width = 3840 -frame_rate = 60 - -# These override the previous by using -e, --high_quality -[high_quality] -pixel_height = 1440 -pixel_width = 2560 -frame_rate = 60 - -# These override the previous by using -m, --medium_quality -[medium_quality] -pixel_height = 720 -pixel_width = 1280 -frame_rate = 30 - -# These override the previous by using -l, --low_quality -[low_quality] -pixel_height = 480 -pixel_width = 854 -frame_rate = 15 - -# These override the previous by using --dry_run -# Note --dry_run overrides all of -w, -a, -s, -g, -i -[dry_run] -write_to_movie = False -write_all = False -save_last_frame = False -save_pngs = False -save_as_gif = False - # Streaming settings [streaming] live_stream_name = LiveStream @@ -170,10 +128,11 @@ streaming_console_banner = Manim is now running in streaming mode. # under media_dir, as is the default. [custom_folders] media_dir = videos -video_dir = %(media_dir)s -images_dir = %(media_dir)s -text_dir = %(media_dir)s/temp_files -tex_dir = %(media_dir)s/temp_files +video_dir = {media_dir} +images_dir = {media_dir} +text_dir = {media_dir}/temp_files +tex_dir = {media_dir}/temp_files +log_dir = {media_dir}/temp_files # Rich settings [logger] @@ -193,5 +152,6 @@ log_height = -1 log_timestamps = True [ffmpeg] -# Uncomment the following line to manually set the loglevel for ffmpeg. See ffmpeg manpage for accepted values -# loglevel = error +# Uncomment the following line to manually set the loglevel for ffmpeg. See +# ffmpeg manpage for accepted values +loglevel = ERROR diff --git a/manim/config/logger.py b/manim/config/logger.py index ca04ac5f4f..2ca472def6 100644 --- a/manim/config/logger.py +++ b/manim/config/logger.py @@ -1,45 +1,66 @@ """ logger.py --------- -This is the logging library for manim. -This library uses rich for coloured log outputs. - -""" +Functions to create and set the logger. -__all__ = ["logger", "console"] - +""" +import os import logging +import json +import copy + from rich.console import Console from rich.logging import RichHandler from rich.theme import Theme -from rich.traceback import install from rich import print as printf from rich import errors, color -import json -import copy +HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially + "Played", + "animations", + "scene", + "Reading", + "Writing", + "script", + "arguments", + "Invalid", + "Aborting", + "module", + "File", + "Rendering", + "Rendered", +] + +WRONG_COLOR_CONFIG_MSG = """ +[logging.level.error]Your colour configuration couldn't be parsed. +Loading the default color configuration.[/logging.level.error] +""" -class JSONFormatter(logging.Formatter): - """Subclass of `:class:`logging.Formatter`, to build our own format of the logs (JSON).""" - def format(self, record): - record_c = copy.deepcopy(record) - if record_c.args: - for arg in record_c.args: - record_c.args[arg] = "<>" - return json.dumps( - { - "levelname": record_c.levelname, - "module": record_c.module, - "message": super().format(record_c), - } - ) +def make_logger(parser, verbosity): + """Make the manim logger and the console.""" + # Throughout the codebase, use Console.print() instead of print() + theme = parse_theme(parser) + console = Console(theme=theme) + # set the rich handler + RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS + rich_handler = RichHandler( + console=console, show_time=parser.getboolean("log_timestamps") + ) -def _parse_theme(config_logger): + # finally, the logger + logger = logging.getLogger("manim") + logger.addHandler(rich_handler) + logger.setLevel(verbosity) + + return logger, console + + +def parse_theme(config_logger): theme = dict( zip( [key.replace("_", ".") for key in config_logger.keys()], @@ -62,59 +83,46 @@ def _parse_theme(config_logger): } ) except (color.ColorParseError, errors.StyleSyntaxError): + printf(WRONG_COLOR_CONFIG_MSG) customTheme = None - printf( - "[logging.level.error]It seems your colour configuration couldn't be parsed. Loading the default color configuration...[/logging.level.error]" - ) - return customTheme + return customTheme -def set_rich_logger(config_logger, verbosity): - """Will set the RichHandler of the logger. - Parameter - ---------- - config_logger :class: - Config object of the logger. - """ - theme = _parse_theme(config_logger) - global console - console = Console(theme=theme) - # These keywords Are Highlighted specially. - RichHandler.KEYWORDS = [ - "Played", - "animations", - "scene", - "Reading", - "Writing", - "script", - "arguments", - "Invalid", - "Aborting", - "module", - "File", - "Rendering", - "Rendered", - ] - rich_handler = RichHandler( - console=console, show_time=config_logger.getboolean("log_timestamps") +def set_file_logger(config, verbosity): + # Note: The log file name will be + # _.log, gotten from config. So it + # can differ from the real name of the scene. would only + # appear if scene name was provided when manim was called. + scene_name_suffix = "".join(config["scene_names"]) + scene_file_name = os.path.basename(config["input_file"]).split(".")[0] + log_file_name = ( + f"{scene_file_name}_{scene_name_suffix}.log" + if scene_name_suffix + else f"{scene_file_name}.log" ) - global logger - rich_handler.setLevel(verbosity) - logger.addHandler(rich_handler) - - -def set_file_logger(log_file_path): + log_file_path = os.path.join(config["log_dir"], log_file_name) file_handler = logging.FileHandler(log_file_path, mode="w") file_handler.setFormatter(JSONFormatter()) - global logger + + logger = logging.getLogger("manim") logger.addHandler(file_handler) + logger.info("Log file will be saved in %(logpath)s", {"logpath": log_file_path}) + logger.setLevel(verbosity) -logger = logging.getLogger("manim") -# The console is set to None as it will be changed by set_rich_logger. -console = None -install() -# TODO : This is only temporary to keep the terminal output clean when working with ImageMobject and matplotlib plots -logging.getLogger("PIL").setLevel(logging.INFO) -logging.getLogger("matplotlib").setLevel(logging.INFO) +class JSONFormatter(logging.Formatter): + """Subclass of `:class:`logging.Formatter`, to build our own format of the logs (JSON).""" + + def format(self, record): + record_c = copy.deepcopy(record) + if record_c.args: + for arg in record_c.args: + record_c.args[arg] = "<>" + return json.dumps( + { + "levelname": record_c.levelname, + "module": record_c.module, + "message": super().format(record_c), + } + ) diff --git a/manim/config/main_utils.py b/manim/config/main_utils.py index 1c937b18d4..6f2f0bc6ff 100644 --- a/manim/config/main_utils.py +++ b/manim/config/main_utils.py @@ -13,26 +13,12 @@ import colour -from manim import constants, logger, config, file_writer_config -from .utils import make_config_parser, JSONFormatter +from manim import constants, logger, config +from .utils import make_config_parser +from .logger import JSONFormatter from ..utils.tex import TexTemplate, TexTemplateFromFile -def init_dirs(config): - for folder in [ - config["media_dir"], - config["video_dir"], - config["tex_dir"], - config["text_dir"], - config["log_dir"], - ]: - if not os.path.exists(folder): - if folder == config["log_dir"] and (not config["log_to_file"]): - pass - else: - os.makedirs(folder) - - def _find_subcommand(args): """Return the subcommand that has been passed, if any. @@ -320,7 +306,7 @@ def _parse_args_no_subcmd(args): parser.add_argument( "-q", "--quality", - choices=constants.QUALITIES.values(), + choices=[constants.QUALITIES[q]["flag"] for q in constants.QUALITIES], default=constants.DEFAULT_QUALITY_SHORT, help="Render at specific quality, short form of the --*_quality flags", ) @@ -437,273 +423,3 @@ def _parse_args_no_subcmd(args): ) return parser.parse_args(args[1:]) - - -def update_config_with_cli(args): - """Update the config dictionaries after parsing CLI flags.""" - parser = make_config_parser() - default = parser["CLI"] - - ## Update config - global config - - # Handle the *_quality flags. These determine the section to read - # and are stored in 'camera_config'. Note the highest resolution - # passed as argument will be used. - quality = _determine_quality(args) - section = parser[quality if quality != constants.DEFAULT_QUALITY else "CLI"] - - # Loop over low quality for the keys, could be any quality really - config.update({opt: section.getint(opt) for opt in parser["low_quality"]}) - - # The -r, --resolution flag overrides the *_quality flags - if args.resolution is not None: - if "," in args.resolution: - height_str, width_str = args.resolution.split(",") - height, width = int(height_str), int(width_str) - else: - height = int(args.resolution) - width = int(16 * height / 9) - config.update({"pixel_height": height, "pixel_width": width}) - - # Handle the -c (--background_color) flag - if args.background_color is not None: - try: - background_color = colour.Color(args.background_color) - except AttributeError as err: - logger.warning("Please use a valid color.") - logger.error(err) - sys.exit(2) - else: - background_color = colour.Color(default["background_color"]) - config["background_color"] = background_color - - config["use_js_renderer"] = args.use_js_renderer or default.getboolean( - "use_js_renderer" - ) - config["js_renderer_path"] = args.js_renderer_path or default.get( - "js_renderer_path" - ) - - # Set the rest of the frame properties - config["frame_height"] = 8.0 - config["frame_width"] = ( - config["frame_height"] * config["pixel_width"] / config["pixel_height"] - ) - config["frame_y_radius"] = config["frame_height"] / 2 - config["frame_x_radius"] = config["frame_width"] / 2 - config["top"] = config["frame_y_radius"] * constants.UP - config["bottom"] = config["frame_y_radius"] * constants.DOWN - config["left_side"] = config["frame_x_radius"] * constants.LEFT - config["right_side"] = config["frame_x_radius"] * constants.RIGHT - - # Handle the --tex_template flag, if the flag is absent read it from the config. - if args.tex_template: - tex_fn = os.path.expanduser(args.tex_template) - else: - tex_fn = default["tex_template"] if default["tex_template"] != "" else None - - if tex_fn is not None and not os.access(tex_fn, os.R_OK): - # custom template not available, fallback to default - logger.warning( - f"Custom TeX template {tex_fn} not found or not readable. " - "Falling back to the default template." - ) - tex_fn = None - config["tex_template_file"] = tex_fn - config["tex_template"] = ( - TexTemplateFromFile(filename=tex_fn) if tex_fn is not None else TexTemplate() - ) - - ## Update file_writer_config - fw_config = {} - - if config["use_js_renderer"]: - fw_config["disable_caching"] = True - - if not hasattr(args, "subcommands"): - fw_config["input_file"] = args.file if args.file else "" - fw_config["scene_names"] = ( - args.scene_names if args.scene_names is not None else [] - ) - fw_config["output_file"] = args.output_file if args.output_file else "" - - # Note ConfigParser options are all strings and each needs to be converted - # to the appropriate type. - for boolean_opt in [ - "preview", - "show_in_file_browser", - "leave_progress_bars", - "write_to_movie", - "save_last_frame", - "save_pngs", - "save_as_gif", - "write_all", - "disable_caching", - "flush_cache", - "log_to_file", - ]: - attr = getattr(args, boolean_opt) - fw_config[boolean_opt] = ( - default.getboolean(boolean_opt) if attr is None else attr - ) - # for str_opt in ['media_dir', 'video_dir', 'tex_dir', 'text_dir']: - for str_opt in ["media_dir"]: - attr = getattr(args, str_opt) - fw_config[str_opt] = os.path.relpath(default[str_opt]) if attr is None else attr - attr = getattr(args, "log_dir") - fw_config["log_dir"] = ( - os.path.join(fw_config["media_dir"], default["log_dir"]) - if attr is None - else attr - ) - dir_names = { - "video_dir": "videos", - "images_dir": "images", - "tex_dir": "Tex", - "text_dir": "texts", - } - for name in dir_names: - fw_config[name] = os.path.join(fw_config["media_dir"], dir_names[name]) - - # the --custom_folders flag overrides the default folder structure with the - # custom folders defined in the [custom_folders] section of the config file - fw_config["custom_folders"] = args.custom_folders - if fw_config["custom_folders"]: - fw_config["media_dir"] = parser["custom_folders"].get("media_dir") - for opt in ["video_dir", "images_dir", "tex_dir", "text_dir"]: - fw_config[opt] = parser["custom_folders"].get(opt) - - # Handle the -s (--save_last_frame) flag: invalidate the -w flag - # At this point the save_last_frame option has already been set by - # both CLI and the cfg file, so read the config dict directly - if fw_config["save_last_frame"]: - fw_config["write_to_movie"] = False - - # Handle the -t (--transparent) flag. This flag determines which - # section to use from the .cfg file. - section = parser["transparent"] if args.transparent else default - for opt in ["png_mode", "movie_file_extension", "background_opacity"]: - fw_config[opt] = section[opt] - - # Handle the -n flag. Read first from the cfg and then override with CLI. - # These two are integers -- use getint() - for opt in ["from_animation_number", "upto_animation_number"]: - fw_config[opt] = default.getint(opt) - if fw_config["upto_animation_number"] == -1: - fw_config["upto_animation_number"] = float("inf") - nflag = args.from_animation_number - if nflag is not None: - if "," in nflag: - start, end = nflag.split(",") - fw_config["from_animation_number"] = int(start) - fw_config["upto_animation_number"] = int(end) - else: - fw_config["from_animation_number"] = int(nflag) - - # Handle the --dry_run flag. This flag determines which section - # to use from the .cfg file. All options involved are boolean. - # Note this overrides the flags -w, -s, -a, -g, and -i. - if args.dry_run: - for opt in [ - "write_to_movie", - "save_last_frame", - "save_pngs", - "save_as_gif", - "write_all", - ]: - fw_config[opt] = parser["dry_run"].getboolean(opt) - if not fw_config["write_to_movie"]: - fw_config["disable_caching"] = True - # Read in the streaming section -- all values are strings - fw_config["streaming"] = { - opt: parser["streaming"][opt] - for opt in [ - "live_stream_name", - "twitch_stream_key", - "streaming_protocol", - "streaming_ip", - "streaming_protocol", - "streaming_client", - "streaming_port", - "streaming_port", - "streaming_console_banner", - ] - } - - # For internal use (no CLI flag) - fw_config["skip_animations"] = fw_config["save_last_frame"] - fw_config["max_files_cached"] = default.getint("max_files_cached") - if fw_config["max_files_cached"] == -1: - fw_config["max_files_cached"] = float("inf") - # Parse the verbosity flag to read in the log level - verbosity = getattr(args, "verbosity") - verbosity = default["verbosity"] if verbosity is None else verbosity - fw_config["verbosity"] = verbosity - logger.setLevel(verbosity) - - # Parse the ffmpeg log level in the config - ffmpeg_loglevel = parser["ffmpeg"].get("loglevel", None) - fw_config["ffmpeg_loglevel"] = ( - constants.FFMPEG_VERBOSITY_MAP[verbosity] - if ffmpeg_loglevel is None - else ffmpeg_loglevel - ) - - # Parse the progress_bar flag - progress_bar = getattr(args, "progress_bar") - if progress_bar is None: - progress_bar = default.getboolean("progress_bar") - fw_config["progress_bar"] = progress_bar - - global file_writer_config - file_writer_config.update(fw_config) - - -def _determine_quality(args): - old_qualities = { - "k": "fourk_quality", - "e": "high_quality", - "m": "medium_quality", - "l": "low_quality", - } - - for quality in constants.QUALITIES: - if quality == constants.DEFAULT_QUALITY: - # Skip so we prioritize anything that overwrites the default quality. - pass - elif getattr(args, quality, None) or ( - hasattr(args, "quality") and args.quality == constants.QUALITIES[quality] - ): - return quality - - for quality in old_qualities: - if getattr(args, quality, None): - logger.warning( - f"Option -{quality} is deprecated please use the --quality/-q flag." - ) - return old_qualities[quality] - - return constants.DEFAULT_QUALITY - - -def set_file_logger(): - # Note: The log file name will be - # _.log, gotten from - # file_writer_config. So it can differ from the real name of the scene. - # would only appear if scene name was provided when manim - # was called. - scene_name_suffix = "".join(file_writer_config["scene_names"]) - scene_file_name = os.path.basename(file_writer_config["input_file"]).split(".")[0] - log_file_name = ( - f"{scene_file_name}_{scene_name_suffix}.log" - if scene_name_suffix - else f"{scene_file_name}.log" - ) - log_file_path = os.path.join(file_writer_config["log_dir"], log_file_name) - - file_handler = logging.FileHandler(log_file_path, mode="w") - file_handler.setFormatter(JSONFormatter()) - - logger.addHandler(file_handler) - logger.info("Log file will be saved in %(logpath)s", {"logpath": log_file_path}) diff --git a/manim/config/utils.py b/manim/config/utils.py index e3354329b5..4f03815de9 100644 --- a/manim/config/utils.py +++ b/manim/config/utils.py @@ -2,48 +2,39 @@ utils.py -------- -Functions to create the logger and config. +Functions to create and set the config. """ import os import sys +import copy import logging -from pathlib import Path import configparser -import json -import copy +from pathlib import Path +from collections.abc import Mapping, MutableMapping +import numpy as np import colour -from rich.console import Console -from rich.logging import RichHandler -from rich.theme import Theme -from rich import print as printf -from rich import errors, color from .. import constants from ..utils.tex import TexTemplate, TexTemplateFromFile +from .logger import set_file_logger -HIGHLIGHTED_KEYWORDS = [ # these keywords are highlighted specially - "Played", - "animations", - "scene", - "Reading", - "Writing", - "script", - "arguments", - "Invalid", - "Aborting", - "module", - "File", - "Rendering", - "Rendered", -] - -WRONG_COLOR_CONFIG_MSG = """ -[logging.level.error]Your colour configuration couldn't be parsed. -Loading the default color configuration.[/logging.level.error] -""" + +def init_dirs(config): + for folder in [ + config["media_dir"], + config["video_dir"], + config["tex_dir"], + config["text_dir"], + config["log_dir"], + ]: + if not os.path.exists(folder): + if folder == config["log_dir"] and (not config["log_to_file"]): + pass + else: + os.makedirs(folder) def config_file_paths(): @@ -56,8 +47,23 @@ def config_file_paths(): return [library_wide, user_wide, folder_wide] -def make_config_parser(): - """Make a ConfigParser object and load the .cfg files.""" +def make_config_parser(custom_file=None): + """Make a ConfigParser object and load the .cfg files. + + Parameters + ---------- + custom_file : str + + Path to a custom config file. If used, the folder-wide file in the + relevant directory will be ignored, if it exists. If None, the + folder-wide file will be used, if it exists. + + Notes + ----- + The folder-wide file can be ignored by passing custom_file. However, the + user-wide and library-wide config files cannot be ignored. + + """ library_wide, user_wide, folder_wide = config_file_paths() # From the documentation: "An application which requires initial values to # be loaded from a file should load the required file or files using @@ -65,206 +71,861 @@ def make_config_parser(): # https://docs.python.org/3/library/configparser.html#configparser.ConfigParser.read parser = configparser.ConfigParser() with open(library_wide) as file: - parser.read_file(file) - parser.read([user_wide, folder_wide]) + parser.read_file(file) # necessary file + + other_files = [user_wide, custom_file if custom_file else folder_wide] + parser.read(other_files) # optional files + return parser -def make_logger(parser, verbosity): - """Make the manim logger and the console.""" - # Throughout the codebase, use Console.print() instead of print() - theme = parse_theme(parser) - console = Console(theme=theme) +def determine_quality(args): + old_qualities = { + "k": "fourk_quality", + "e": "high_quality", + "m": "medium_quality", + "l": "low_quality", + } - # set the rich handler - RichHandler.KEYWORDS = HIGHLIGHTED_KEYWORDS - rich_handler = RichHandler( - console=console, show_time=parser.getboolean("log_timestamps") - ) - rich_handler.setLevel(verbosity) + for quality in constants.QUALITIES: + if quality == constants.DEFAULT_QUALITY: + # Skip so we prioritize anything that overwrites the default quality. + pass + elif getattr(args, quality, None) or ( + hasattr(args, "quality") + and args.quality == constants.QUALITIES[quality]["flag"] + ): + return quality + + for quality in old_qualities: + if getattr(args, quality, None): + logging.getLogger("manim").warning( + f"Option -{quality} is deprecated please use the --quality/-q flag." + ) + return old_qualities[quality] + + return constants.DEFAULT_QUALITY - # finally, the logger - logger = logging.getLogger("manim") - logger.addHandler(rich_handler) - return logger, console +class ManimConfig(MutableMapping): + _OPTS = { + "background_color", + "background_opacity", + "custom_folders", + "disable_caching", + "ffmpeg_loglevel", + "flush_cache", + "frame_height", + "frame_rate", + "frame_width", + "frame_x_radius", + "frame_y_radius", + "from_animation_number", + "images_dir", + "input_file", + "js_renderer_path", + "leave_progress_bars", + "log_dir", + "log_to_file", + "max_files_cached", + "media_dir", + "movie_file_extension", + "pixel_height", + "pixel_width", + "png_mode", + "preview", + "progress_bar", + "save_as_gif", + "save_last_frame", + "save_pngs", + "scene_names", + "show_in_file_browser", + "skip_animations", + "sound", + "tex_dir", + "tex_template_file", + "text_dir", + "upto_animation_number", + "use_js_renderer", + "verbosity", + "video_dir", + "write_all", + "write_to_movie", + } -def parse_theme(config_logger): - theme = dict( - zip( - [key.replace("_", ".") for key in config_logger.keys()], - list(config_logger.values()), + def __init__(self, parser=None): + self._d = {k: None for k in self._OPTS} + self._parser = parser + if parser: + self.digest_parser(parser) + + # behave like a dict + def __iter__(self): + return iter(self._d) + + def __len__(self): + return len(self._d) + + def __contains__(self, key): + try: + self.__getitem__(key) + return True + except AttributeError: + return False + + def __getitem__(self, key): + return getattr(self, key) + + def __setitem__(self, key, val): + getattr(ManimConfig, key).fset(self, val) # fset is the property's setter + + def update(self, obj): + if isinstance(obj, ManimConfig): + self._d.update(obj._d) + + elif isinstance(obj, dict): + # First update the underlying _d, then update other properties + _dict = {k: v for k, v in obj.items() if k in self._d} + for k, v in _dict.items(): + self[k] = v + + _dict = {k: v for k, v in obj.items() if k not in self._d} + for k, v in _dict.items(): + self[k] = v + + # don't allow to delete anything + def __delitem__(self, key): + raise AttributeError("'ManimConfig' object does not support item deletion") + + def __delattr__(self, key): + raise AttributeError("'ManimConfig' object does not support item deletion") + + # copy functions + def copy(self): + return copy.deepcopy(self) + + def __copy__(self): + return copy.deepcopy(self) + + def __deepcopy__(self, memo): + c = ManimConfig() + c._d = copy.deepcopy(self._d, memo) + return c + + # helper type-checking methods + def _set_from_list(self, key, val, values): + if val in values: + self._d[key] = val + else: + raise ValueError(f"attempted to set {key} to {val}; must be in {values}") + + def _set_boolean(self, key, val): + if val in [True, False]: + self._d[key] = val + else: + raise ValueError(f"{key} must be boolean") + + def _set_str(self, key, val): + if isinstance(val, str): + self._d[key] = val + elif not val: + self._d[key] = "" + else: + raise ValueError(f"{key} must be str or falsy value") + + def _set_between(self, key, val, lo, hi): + if lo <= val <= hi: + self._d[key] = val + else: + raise ValueError(f"{key} must be {lo} <= {key} <= {hi}") + + def _set_pos_number(self, key, val, allow_inf): + if isinstance(val, int) and val > -1: + self._d[key] = val + elif allow_inf and (val == -1 or val == float("inf")): + self._d[key] = float("inf") + else: + raise ValueError( + f"{key} must be a non-negative integer (use -1 for infinity)" + ) + + # builders + def digest_parser(self, parser): + self._parser = parser + + # boolean keys + for key in [ + "write_to_movie", + "save_last_frame", + "write_all", + "save_pngs", + "save_as_gif", + "preview", + "show_in_file_browser", + "progress_bar", + "sound", + "leave_progress_bars", + "log_to_file", + "disable_caching", + "flush_cache", + "custom_folders", + "skip_animations", + "use_js_renderer", + ]: + setattr(self, key, parser["CLI"].getboolean(key, fallback=False)) + + # int keys + for key in [ + "from_animation_number", + "upto_animation_number", + "frame_rate", + "max_files_cached", + "pixel_height", + "pixel_width", + ]: + setattr(self, key, parser["CLI"].getint(key)) + + # str keys + for key in [ + "verbosity", + "media_dir", + "log_dir", + "video_dir", + "images_dir", + "text_dir", + "tex_dir", + "input_file", + "output_file", + "png_mode", + "movie_file_extension", + "background_color", + "js_renderer_path", + ]: + setattr(self, key, parser["CLI"].get(key, fallback="", raw=True)) + + # float keys + for key in ["background_opacity"]: + setattr(self, key, parser["CLI"].getfloat(key)) + + # other logic + self["frame_height"] = 8.0 + self["frame_width"] = ( + self["frame_height"] * self["pixel_width"] / self["pixel_height"] ) + + val = parser["CLI"].get("tex_template_file") + if val: + setattr(self, "tex_template_file", val) + + val = parser["ffmpeg"].get("loglevel") + if val: + setattr(self, "ffmpeg_loglevel", val) + + return self + + def digest_args(self, args): + # if a config file has been passed, digest it first so that other CLI + # flags supersede it + if args.config_file: + self.digest_file(args.config_file) + + self.input_file = args.file + self.scene_names = args.scene_names if args.scene_names is not None else [] + self.output_file = args.output_file + + for key in [ + "preview", + "show_in_file_browser", + "sound", + "leave_progress_bars", + "write_to_movie", + "save_last_frame", + "save_pngs", + "save_as_gif", + "write_all", + "disable_caching", + "flush_cache", + "transparent", + "scene_names", + "verbosity", + "background_color", + ]: + if hasattr(args, key): + attr = getattr(args, key) + # if attr is None, then no argument was passed and we should + # not change the current config + if attr is not None: + self[key] = attr + + # dry_run is special because it can only be set to True + if hasattr(args, "dry_run"): + if getattr(args, "dry_run"): + self["dry_run"] = True + + for key in [ + "media_dir", # always set this one first + "video_dir", + "images_dir", + "tex_dir", + "text_dir", + "log_dir", + "custom_folders", + "log_to_file", # always set this one last + ]: + if hasattr(args, key): + attr = getattr(args, key) + # if attr is None, then no argument was passed and we should + # not change the current config + if attr is not None: + self[key] = attr + + # The -s (--save_last_frame) flag invalidates -w (--write_to_movie). + if self["save_last_frame"]: + self["write_to_movie"] = False + + # Handle the -n flag. + nflag = args.from_animation_number + if nflag is not None: + if "," in nflag: + start, end = nflag.split(",") + self.from_animation_number = int(start) + self.upto_animation_number = int(end) + else: + self.from_animation_number = int(nflag) + + # Handle the quality flags + self.quality = determine_quality(args) + + # Handle the -r flag. + rflag = args.resolution + if rflag is not None: + try: + w, h = rflag.split(",") + self.pixel_width = int(w) + self.pixel_height = int(h) + except ValueError: + raise ValueError( + f'invalid argument {rflag} for -r flag (must have a comma ",")' + ) + + # Handle --custom_folders + if args.custom_folders: + for opt in ["media_dir", "video_dir", "text_dir", "tex_dir", "log_dir"]: + self[opt] = self._parser["custom_folders"].get(opt, raw=True) + + return self + + def digest_dict(self, _dict): + pass + + def digest_file(self, filename): + if filename: + return self.digest_parser(make_config_parser(filename)) + + # config options are properties + preview = property( + lambda self: self._d["preview"], + lambda self, val: self._set_boolean("preview", val), + doc="Whether to play the movie once it is done rendering", ) - theme["log.width"] = None if theme["log.width"] == "-1" else int(theme["log.width"]) + show_in_file_browser = property( + lambda self: self._d["show_in_file_browser"], + lambda self, val: self._set_boolean("show_in_file_browser", val), + doc="Whether to show the rendered file in the file browser", + ) - theme["log.height"] = ( - None if theme["log.height"] == "-1" else int(theme["log.height"]) + progress_bar = property( + lambda self: self._d["progress_bar"], + lambda self, val: self._set_boolean("progress_bar", val), + doc="Whether to show progress bars while rendering animations", ) - theme["log.timestamps"] = False - try: - customTheme = Theme( - { - k: v - for k, v in theme.items() - if k not in ["log.width", "log.height", "log.timestamps"] - } - ) - except (color.ColorParseError, errors.StyleSyntaxError): - printf(WRONG_COLOR_CONFIG_MSG) - customTheme = None - - return customTheme - - -def make_config(parser): - """Parse config files into a single dictionary exposed to the user.""" - # By default, use the CLI section of the digested .cfg files - default = parser["CLI"] - - # Loop over [low_quality] for the keys, but get the values from [CLI] - config = {opt: default.getint(opt) for opt in parser["low_quality"]} - - # Set the rest of the frame properties - config["default_pixel_height"] = default.getint("pixel_height") - config["default_pixel_width"] = default.getint("pixel_width") - config["background_color"] = colour.Color(default["background_color"]) - config["frame_height"] = 8.0 - config["frame_width"] = ( - config["frame_height"] * config["pixel_width"] / config["pixel_height"] - ) - config["frame_y_radius"] = config["frame_height"] / 2 - config["frame_x_radius"] = config["frame_width"] / 2 - config["top"] = config["frame_y_radius"] * constants.UP - config["bottom"] = config["frame_y_radius"] * constants.DOWN - config["left_side"] = config["frame_x_radius"] * constants.LEFT - config["right_side"] = config["frame_x_radius"] * constants.RIGHT - - # Tex template - tex_fn = None if not default["tex_template"] else default["tex_template"] - if tex_fn is not None and not os.access(tex_fn, os.R_OK): - # custom template not available, fallback to default - logging.getLogger("manim").warning( - f"Custom TeX template {tex_fn} not found or not readable. " - "Falling back to the default template." + + leave_progress_bars = property( + lambda self: self._d["leave_progress_bars"], + lambda self, val: self._set_boolean("leave_progress_bars", val), + doc="Whether to leave the progress bar for each animations", + ) + + @property + def log_to_file(self): + """Whether to save logs to a file""" + return self._d["log_to_file"] + + @log_to_file.setter + def log_to_file(self, val): + self._set_boolean("log_to_file", val) + if val: + if not os.path.exists(self["log_dir"]): + os.makedirs(self["log_dir"]) + set_file_logger(self, self["verbosity"]) + + sound = property( + lambda self: self._d["sound"], + lambda self, val: self._set_boolean("sound", val), + doc="Whether to play a sound to notify when a scene is rendered", + ) + + write_to_movie = property( + lambda self: self._d["write_to_movie"], + lambda self, val: self._set_boolean("write_to_movie", val), + doc="Whether to render the scene to a movie file", + ) + + save_last_frame = property( + lambda self: self._d["save_last_frame"], + lambda self, val: self._set_boolean("save_last_frame", val), + doc="Whether to save the last frame of the scene as an image file", + ) + + write_all = property( + lambda self: self._d["write_all"], + lambda self, val: self._set_boolean("write_all", val), + doc="Whether to render all scenes in the input file", + ) + + save_pngs = property( + lambda self: self._d["save_pngs"], + lambda self, val: self._set_boolean("save_pngs", val), + doc="Whether to save all frames in the scene as images files", + ) + + save_as_gif = property( + lambda self: self._d["save_as_gif"], + lambda self, val: self._set_boolean("save_as_gif", val), + doc="Whether to save the rendered scene in .gif format.", + ) + + @property + def verbosity(self): + return self._d["verbosity"] + + @verbosity.setter + def verbosity(self, val): + """Verbosity level of the logger.""" + self._set_from_list( + "verbosity", + val, + ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], ) - tex_fn = None - config["tex_template_file"] = tex_fn - config["tex_template"] = ( - TexTemplate() if not tex_fn else TexTemplateFromFile(filename=tex_fn) + logging.getLogger("manim").setLevel(val) + + ffmpeg_loglevel = property( + lambda self: self._d["ffmpeg_loglevel"], + lambda self, val: self._set_from_list( + "ffmpeg_loglevel", val, ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + ), + doc="Verbosity level of ffmpeg.", ) - # Choose the renderer - config["use_js_renderer"] = default.getboolean("use_js_renderer") - config["js_renderer_path"] = default.get("js_renderer_path") + pixel_width = property( + lambda self: self._d["pixel_width"], + lambda self, val: self._set_pos_number("pixel_width", val, False), + doc="Frame width in pixels", + ) - return config + pixel_height = property( + lambda self: self._d["pixel_height"], + lambda self, val: self._set_pos_number("pixel_height", val, False), + doc="Frame height in pixels", + ) + aspect_ratio = property( + lambda self: self._d["pixel_width"] / self._d["pixel_height"], + doc="Aspect ratio (width / height) in pixels", + ) -def make_file_writer_config(parser, config): - """Parse config files into a single dictionary used internally.""" - # By default, use the CLI section of the digested .cfg files - default = parser["CLI"] + frame_height = property( + lambda self: self._d["frame_height"], + lambda self, val: self._d.__setitem__("frame_height", val), + doc="Frame height in logical units", + ) - # This will be the final file_writer_config dict exposed to the user - fw_config = {} + frame_width = property( + lambda self: self._d["frame_width"], + lambda self, val: self._d.__setitem__("frame_width", val), + doc="Frame width in logical units", + ) - # These may be overriden by CLI arguments - fw_config["input_file"] = "" - fw_config["scene_names"] = "" - fw_config["output_file"] = "" - fw_config["custom_folders"] = False + frame_y_radius = property( + lambda self: self._d["frame_height"] / 2, + lambda self, val: ( + self._d.__setitem__("frame_y_radius", val) + or self._d.__setitem__("frame_height", 2 * val) + ), + doc="Half the frame height", + ) - # Note ConfigParser options are all strings and each needs to be converted - # to the appropriate type. - for boolean_opt in [ - "preview", - "show_in_file_browser", - "leave_progress_bars", - "write_to_movie", - "save_last_frame", - "save_pngs", - "save_as_gif", - "write_all", - "disable_caching", - "flush_cache", - "log_to_file", - "progress_bar", - ]: - fw_config[boolean_opt] = default.getboolean(boolean_opt) + frame_x_radius = property( + lambda self: self._d["frame_width"] / 2, + lambda self, val: ( + self._d.__setitem__("frame_x_radius", val) + or self._d.__setitem__("frame_width", 2 * val) + ), + doc="Half the frame width", + ) - for str_opt in [ - "png_mode", - "movie_file_extension", - "background_opacity", - ]: - fw_config[str_opt] = default.get(str_opt) + top = property( + lambda self: self.frame_y_radius * constants.UP, + doc="One unit step in the positive vertical direction", + ) - for int_opt in [ - "from_animation_number", - "upto_animation_number", - ]: - fw_config[int_opt] = default.getint(int_opt) - if fw_config["upto_animation_number"] == -1: - fw_config["upto_animation_number"] = float("inf") - - # for str_opt in ['media_dir', 'video_dir', 'tex_dir', 'text_dir']: - for str_opt in ["media_dir", "log_dir"]: - fw_config[str_opt] = Path(default[str_opt]).relative_to(".") - dir_names = { - "video_dir": "videos", - "images_dir": "images", - "tex_dir": "Tex", - "text_dir": "texts", - } - for name in dir_names: - fw_config[name] = fw_config["media_dir"] / dir_names[name] + bottom = property( + lambda self: self.frame_y_radius * constants.DOWN, + doc="One unit step in the negative vertical direction", + ) + + left_side = property( + lambda self: self.frame_x_radius * constants.LEFT, + doc="One unit step in the negative horizontal direction", + ) - if not fw_config["write_to_movie"]: - fw_config["disable_caching"] = True + right_side = property( + lambda self: self.frame_x_radius * constants.RIGHT, + doc="One unit step in the positive horizontal direction", + ) - if config["use_js_renderer"]: - file_writer_config["disable_caching"] = True + frame_rate = property( + lambda self: self._d["frame_rate"], + lambda self, val: self._d.__setitem__("frame_rate", val), + doc="Frame rate in fps (rames per second)", + ) - # Read in the streaming section -- all values are strings - fw_config["streaming"] = { - opt: parser["streaming"].get(opt) for opt in parser["streaming"] - } + background_color = property( + lambda self: self._d["background_color"], + lambda self, val: self._d.__setitem__("background_color", colour.Color(val)), + doc="Background color of the scene.", + ) - # For internal use (no CLI flag) - fw_config["skip_animations"] = fw_config["save_last_frame"] - fw_config["max_files_cached"] = default.getint("max_files_cached") - if fw_config["max_files_cached"] == -1: - fw_config["max_files_cached"] = float("inf") + from_animation_number = property( + lambda self: self._d["from_animation_number"], + lambda self, val: self._d.__setitem__("from_animation_number", val), + doc="Set to a number greater than 1 to skip animations.", + ) - # Parse the verbosity flag to read in the log level - fw_config["verbosity"] = default["verbosity"] + upto_animation_number = property( + lambda self: self._d["upto_animation_number"], + lambda self, val: self._set_pos_number("upto_animation_number", val, True), + doc=( + "Set to less than the number of animations to skip " + "animations. Use -1 to avoid skipping." + ), + ) - # Parse the ffmpeg log level in the config - ffmpeg_loglevel = parser["ffmpeg"].get("loglevel", None) - fw_config["ffmpeg_loglevel"] = ( - constants.FFMPEG_VERBOSITY_MAP[fw_config["verbosity"]] - if ffmpeg_loglevel is None - else ffmpeg_loglevel + skip_animations = property( + lambda self: self._d["skip_animations"], + lambda self, val: self._set_boolean("skip_animations", val), + doc=( + "Set to less than the number of animations to skip " + "animations. Use -1 to avoid skipping." + ), ) - return fw_config + max_files_cached = property( + lambda self: self._d["max_files_cached"], + lambda self, val: self._set_pos_number("max_files_cached", val, True), + doc="Maximum number of files cached. Use -1 for infinity.", + ) + flush_cache = property( + lambda self: self._d["flush_cache"], + lambda self, val: self._set_boolean("flush_cache", val), + doc="whether to delete all the cached partial movie files.", + ) -class JSONFormatter(logging.Formatter): - """Subclass of `:class:`logging.Formatter`, to build our own format of the logs (JSON).""" + disable_caching = property( + lambda self: self._d["disable_caching"], + lambda self, val: self._set_boolean("disable_caching", val), + doc="whether to use scene caching.", + ) + + png_mode = property( + lambda self: self._d["png_mode"], + lambda self, val: self._set_from_list("png_mode", val, ["RGB", "RGBA"]), + doc="Either RGA (no transparency) or RGBA (with transparency).", + ) + + movie_file_extension = property( + lambda self: self._d["movie_file_extension"], + lambda self, val: self._set_from_list( + "movie_file_extension", val, [".mp4", ".mov"] + ), + doc="Either .mp4 or .mov.", + ) + + background_opacity = property( + lambda self: self._d["background_opacity"], + lambda self, val: self._set_between("background_opacity", val, 0, 1), + doc="A number between 0.0 (fully transparent) and 1.0 (fully opaque).", + ) + + frame_size = property( + lambda self: (self._d["pixel_width"], self._d["pixel_height"]), + lambda self, tup: ( + self._d.__setitem__("pixel_width", tup[0]) + or self._d.__setitem__("pixel_height", tup[1]) + ), + doc="", + ) - def format(self, record): - record_c = copy.deepcopy(record) - if record_c.args: - for arg in record_c.args: - record_c.args[arg] = "<>" - return json.dumps( - { - "levelname": record_c.levelname, - "module": record_c.module, - "message": super().format(record_c), - } + @property + def quality(self): + """Video quality.""" + keys = ["pixel_width", "pixel_height", "frame_rate"] + q = {k: self[k] for k in keys} + for qual in constants.QUALITIES: + if all([q[k] == constants.QUALITIES[qual][k] for k in keys]): + return qual + else: + return None + + @quality.setter + def quality(self, qual): + if qual not in constants.QUALITIES: + raise KeyError(f"quality must be one of {list(constants.QUALITIES.keys())}") + q = constants.QUALITIES[qual] + self.frame_size = q["pixel_width"], q["pixel_height"] + self.frame_rate = q["frame_rate"] + + @property + def transparent(self): + """Whether the background opacity is 0.0.""" + return self._d["background_opacity"] == 0.0 + + @transparent.setter + def transparent(self, val): + if val: + self.png_mode = "RGBA" + self.movie_file_extension = ".mov" + self.background_opacity = 0.0 + else: + self.png_mode = "RGB" + self.movie_file_extension = ".mp4" + self.background_opacity = 1.0 + + @property + def dry_run(self): + """Whether dry run is enabled.""" + return ( + self.write_to_movie is False + and self.write_all is False + and self.save_last_frame is False + and self.save_pngs is False + and self.save_as_gif is False ) + + @dry_run.setter + def dry_run(self, val): + if val: + self.write_to_movie = False + self.write_all = False + self.save_last_frame = False + self.save_pngs = False + self.save_as_gif = False + else: + raise ValueError( + "It is unclear what it means to set dry_run to " + "False. Instead, try setting each option " + "individually. (write_to_movie, write_alll, " + "save_last_frame, save_pngs, or save_as_gif)" + ) + + @property + def use_js_renderer(self): + self._d["use_js_renderer"] + + @use_js_renderer.setter + def use_js_renderer(self, val): + self._d["use_js_renderer"] = val + if val: + self["disable_caching"] = True + + js_renderer_path = property( + lambda self: self._d["js_renderer_path"], + lambda self, val: self._d.__setitem__("js_renderer_path", val), + doc="Path to JS renderer.", + ) + + media_dir = property( + lambda self: Path(self._d["media_dir"]), + lambda self, val: self._d.__setitem__("media_dir", val), + doc="Main output directory, relative to execution directory.", + ) + + def _get_dir(self, key): + dirs = [ + "media_dir", + "video_dir", + "images_dir", + "text_dir", + "tex_dir", + "log_dir", + "input_file", + "output_file", + ] + dirs.remove(key) + dirs = {k: self._d[k] for k in dirs} + path = self._d[key].format(**dirs) + return Path(path) if path else None + + def _set_dir(self, key, val): + if isinstance(val, Path): + self._d.__setitem__(key, str(val)) + else: + self._d.__setitem__(key, val) + + log_dir = property( + lambda self: self._get_dir("log_dir"), + lambda self, val: self._set_dir("log_dir", val), + doc="Directory to place logs", + ) + + video_dir = property( + lambda self: self._get_dir("video_dir"), + lambda self, val: self._set_dir("video_dir", val), + doc="Directory to place videos", + ) + + images_dir = property( + lambda self: self._get_dir("images_dir"), + lambda self, val: self._set_dir("images_dir", val), + doc="Directory to place images", + ) + + text_dir = property( + lambda self: self._get_dir("text_dir"), + lambda self, val: self._set_dir("text_dir", val), + doc="Directory to place text", + ) + + tex_dir = property( + lambda self: self._get_dir("tex_dir"), + lambda self, val: self._set_dir("tex_dir", val), + doc="Directory to place tex", + ) + + custom_folders = property( + lambda self: self._d["custom_folders"], + lambda self, val: self._set_boolean("custom_folders", val), + doc="Whether to use custom folders.", + ) + + input_file = property( + lambda self: self._get_dir("input_file"), + lambda self, val: self._set_dir("input_file", val), + doc="Input file name.", + ) + + output_file = property( + lambda self: self._get_dir("output_file"), + lambda self, val: self._set_dir("output_file", val), + doc="Output file name.", + ) + + scene_names = property( + lambda self: self._d["scene_names"], + lambda self, val: self._d.__setitem__("scene_names", val), + doc="Scenes to play from file.", + ) + + @property + def tex_template(self): + if not hasattr(self, "_tex_template") or not self._tex_template: + fn = self._d["tex_template_file"] + if fn: + self._tex_template = TexTemplateFromFile(filename=fn) + else: + self._tex_template = TexTemplate() + return self._tex_template + + @tex_template.setter + def tex_template(self, val): + if isinstance(val, (TexTemplateFromFile, TexTemplate)): + self._tex_template = val + + @property + def tex_template_file(self): + return self._d["tex_template_file"] + + @tex_template_file.setter + def tex_template_file(self, val): + if val: + if not os.access(val, os.R_OK): + logging.getLogger("manim").warning( + f"Custom TeX template {val} not found or not readable." + ) + else: + self._d["tex_template_file"] = Path(val) + self._tex_template = TexTemplateFromFile(filename=val) + else: + self._d["tex_template_file"] = val # actually set the falsy value + self._tex_template = TexTemplate() # but don't use it + + +class ManimFrame(Mapping): + _OPTS = { + "pixel_width", + "pixel_height", + "aspect_ratio", + "frame_height", + "frame_width", + "frame_y_radius", + "frame_x_radius", + "top", + "bottom", + "left_side", + "right_side", + } + _CONSTANTS = { + "UP": np.array((0.0, 1.0, 0.0)), + "DOWN": np.array((0.0, -1.0, 0.0)), + "RIGHT": np.array((1.0, 0.0, 0.0)), + "LEFT": np.array((-1.0, 0.0, 0.0)), + "IN": np.array((0.0, 0.0, -1.0)), + "OUT": np.array((0.0, 0.0, 1.0)), + "ORIGIN": np.array((0.0, 0.0, 0.0)), + "X_AXIS": np.array((1.0, 0.0, 0.0)), + "Y_AXIS": np.array((0.0, 1.0, 0.0)), + "Z_AXIS": np.array((0.0, 0.0, 1.0)), + "UL": np.array((-1.0, 1.0, 0.0)), + "UR": np.array((1.0, 1.0, 0.0)), + "DL": np.array((-1.0, -1.0, 0.0)), + "DR": np.array((1.0, -1.0, 0.0)), + } + + def __init__(self, c): + if not isinstance(c, ManimConfig): + raise TypeError("argument must be instance of 'ManimConfig'") + # need to use __dict__ directly because setting attributes is not + # allowed (see __setattr__) + self.__dict__["_c"] = c + + # there are required by parent class Mapping to behave like a dict + def __getitem__(self, key): + if key in self._OPTS: + return self._c[key] + elif key in self._CONSTANTS: + return self._CONSTANTS[key] + else: + raise KeyError(key) + + def __iter__(self): + return iter(list(self._OPTS) + list(self._CONSTANTS)) + + def __len__(self): + return len(self._OPTS) + + # make this truly immutable + def __setattr__(self, attr, val): + raise TypeError("'ManimFrame' object does not support item assignment") + + def __setitem__(self, key, val): + raise TypeError("'ManimFrame' object does not support item assignment") + + def __delitem__(self, key): + raise TypeError("'ManimFrame' object does not support item deletion") + + +for opt in list(ManimFrame._OPTS) + list(ManimFrame._CONSTANTS): + setattr(ManimFrame, opt, property(lambda self, o=opt: self[o])) diff --git a/manim/constants.py b/manim/constants.py index 2f13f226ea..ec78076db5 100644 --- a/manim/constants.py +++ b/manim/constants.py @@ -141,12 +141,37 @@ class MyText(Text): # Video qualities QUALITIES = { - "fourk_quality": "k", - "production_quality": "p", - "high_quality": "h", - "medium_quality": "m", - "low_quality": "l", + "fourk_quality": { + "flag": "k", + "pixel_height": 2160, + "pixel_width": 3840, + "frame_rate": 60, + }, + "production_quality": { + "flag": "p", + "pixel_height": 1440, + "pixel_width": 2560, + "frame_rate": 60, + }, + "high_quality": { + "flag": "h", + "pixel_height": 1080, + "pixel_width": 1920, + "frame_rate": 60, + }, + "medium_quality": { + "flag": "m", + "pixel_height": 720, + "pixel_width": 1280, + "frame_rate": 30, + }, + "low_quality": { + "flag": "l", + "pixel_height": 480, + "pixel_width": 854, + "frame_rate": 15, + }, } -DEFAULT_QUALITY = "production_quality" -DEFAULT_QUALITY_SHORT = QUALITIES[DEFAULT_QUALITY] +DEFAULT_QUALITY = "high_quality" +DEFAULT_QUALITY_SHORT = QUALITIES[DEFAULT_QUALITY]["flag"] diff --git a/manim/grpc/impl/frame_server_impl.py b/manim/grpc/impl/frame_server_impl.py index 0889a07438..5960e7430d 100644 --- a/manim/grpc/impl/frame_server_impl.py +++ b/manim/grpc/impl/frame_server_impl.py @@ -1,5 +1,4 @@ -from ...config import camera_config -from ...config import file_writer_config +from ...config import config from ...scene import scene from ..gen import frameserver_pb2 from ..gen import frameserver_pb2_grpc @@ -53,7 +52,7 @@ def __init__(self, server, scene_class): except grpc._channel._InactiveRpcError: logger.warning(f"No frontend was detected at localhost:50052.") try: - sp.Popen(camera_config["js_renderer_path"]) + sp.Popen(config["js_renderer_path"]) except PermissionError: logger.info(JS_RENDERER_INFO) self.server.stop(None) @@ -194,7 +193,7 @@ def on_deleted(self, event): def on_modified(self, event): super().on_modified(event) - module = get_module(file_writer_config["input_file"]) + module = get_module(config["input_file"]) all_scene_classes = get_scene_classes_from_module(module) scene_classes_to_render = get_scenes_to_render(all_scene_classes) scene_class = scene_classes_to_render[0] @@ -250,7 +249,7 @@ def on_modified(self, event): try: stub.ManimStatus(request) except grpc._channel._InactiveRpcError: - sp.Popen(camera_config["js_renderer_path"]) + sp.Popen(config["js_renderer_path"]) def get(scene_class): diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index 8f58c5d0d8..5a34582183 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -15,7 +15,7 @@ from colour import Color import numpy as np -from .. import config, file_writer_config +from .. import config from ..constants import * from ..container import Container from ..utils.color import color_gradient, WHITE, BLACK, YELLOW_C @@ -199,7 +199,7 @@ def show(self, camera=None): def save_image(self, name=None): self.get_image().save( - Path(file_writer_config["video_dir"]).joinpath((name or str(self)) + ".png") + Path(config["video_dir"]).joinpath((name or str(self)) + ".png") ) def copy(self): diff --git a/manim/mobject/svg/text_mobject.py b/manim/mobject/svg/text_mobject.py index 969123b22f..204834ed11 100644 --- a/manim/mobject/svg/text_mobject.py +++ b/manim/mobject/svg/text_mobject.py @@ -13,7 +13,7 @@ import pangocairocffi import pangocffi -from ... import config, file_writer_config, logger +from ... import config, logger from ...constants import * from ...container import Container from ...mobject.geometry import Dot, Rectangle @@ -310,7 +310,7 @@ def text2svg(self): if NOT_SETTING_FONT_MSG: logger.warning(NOT_SETTING_FONT_MSG) - dir_name = file_writer_config["text_dir"] + dir_name = config["text_dir"] if not os.path.exists(dir_name): os.makedirs(dir_name) @@ -884,7 +884,7 @@ def text2svg(self): """ size = self.size * 10 line_spacing = self.line_spacing * 10 - dir_name = file_writer_config["text_dir"] + dir_name = config["text_dir"] if not os.path.exists(dir_name): os.makedirs(dir_name) hash_name = self.text2hash() diff --git a/manim/renderer/cairo_renderer.py b/manim/renderer/cairo_renderer.py index 1628c3bf30..0d83993679 100644 --- a/manim/renderer/cairo_renderer.py +++ b/manim/renderer/cairo_renderer.py @@ -1,5 +1,5 @@ import numpy as np -from .. import config, camera_config, file_writer_config +from .. import config from ..utils.iterables import list_update from ..utils.exceptions import EndSceneEarlyException from ..constants import DEFAULT_WAIT_TIME @@ -37,7 +37,7 @@ def handle_play_like_call(func): """ def wrapper(self, scene, *args, **kwargs): - allow_write = not file_writer_config["skip_animations"] + allow_write = not config["skip_animations"] self.file_writer.begin_animation(allow_write) func(self, scene, *args, **kwargs) self.file_writer.end_animation(allow_write) @@ -71,8 +71,8 @@ def __init__(self, camera_class=None, **kwargs): ]: self.video_quality_config[attr] = kwargs.get(attr, config[attr]) camera_cls = camera_class if camera_class is not None else Camera - self.camera = camera_cls(self.video_quality_config, **camera_config) - self.original_skipping_status = file_writer_config["skip_animations"] + self.camera = camera_cls(self.video_quality_config) + self.original_skipping_status = config["skip_animations"] self.animations_hashes = [] self.num_plays = 0 self.time = 0 @@ -82,7 +82,6 @@ def init(self, scene): self, self.video_quality_config, scene.__class__.__name__, - **file_writer_config, ) @pass_scene_reference @@ -117,7 +116,7 @@ def update_frame( # TODO Description in Docstring **kwargs """ - if file_writer_config["skip_animations"] and not ignore_skipping: + if config["skip_animations"] and not ignore_skipping: return if mobjects is None: mobjects = list_update( @@ -157,7 +156,7 @@ def add_frame(self, frame, num_frames=1): """ dt = 1 / self.camera.frame_rate self.time += num_frames * dt - if file_writer_config["skip_animations"]: + if config["skip_animations"]: return for _ in range(num_frames): self.file_writer.write_frame(frame) @@ -178,12 +177,12 @@ def update_skipping_status(self): the number of animations that need to be played, and raises an EndSceneEarlyException if they don't correspond. """ - if file_writer_config["from_animation_number"]: - if self.num_plays < file_writer_config["from_animation_number"]: - file_writer_config["skip_animations"] = True - if file_writer_config["upto_animation_number"]: - if self.num_plays > file_writer_config["upto_animation_number"]: - file_writer_config["skip_animations"] = True + if config["from_animation_number"]: + if self.num_plays < config["from_animation_number"]: + config["skip_animations"] = True + if config["upto_animation_number"]: + if self.num_plays > config["upto_animation_number"]: + config["skip_animations"] = True raise EndSceneEarlyException() def revert_to_original_skipping_status(self): @@ -198,12 +197,12 @@ def revert_to_original_skipping_status(self): The Scene, with the original skipping status. """ if hasattr(self, "original_skipping_status"): - file_writer_config["skip_animations"] = self.original_skipping_status + config["skip_animations"] = self.original_skipping_status return self def finish(self, scene): - file_writer_config["skip_animations"] = False + config["skip_animations"] = False self.file_writer.finish() - if file_writer_config["save_last_frame"]: - self.update_frame(scene, ignore_skipping=True) + if config["save_last_frame"]: + self.update_frame(scene, ignore_skipping=False) self.file_writer.save_final_image(self.camera.get_image()) diff --git a/manim/scene/scene.py b/manim/scene/scene.py index bc3860915b..1c31ca10d2 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -13,7 +13,7 @@ from tqdm import tqdm as ProgressDisplay import numpy as np -from .. import camera_config, file_writer_config, logger +from .. import config, logger from ..animation.animation import Animation, Wait from ..animation.transform import MoveToTarget, ApplyMethod from ..camera.camera import Camera @@ -84,11 +84,14 @@ def render(self): """ Render this Scene. """ + self.original_skipping_status = config["skip_animations"] try: self.construct() except EndSceneEarlyException: pass self.tear_down() + # We have to reset these settings in case of multiple renders. + config["skip_animations"] = self.original_skipping_status self.renderer.finish(self) logger.info( f"Rendered {str(self)}\nPlayed {self.renderer.num_plays} animations" @@ -650,7 +653,7 @@ def get_time_progression( ProgressDisplay The CommandLine Progress Bar. """ - if file_writer_config["skip_animations"] and not override_skip_animations: + if config["skip_animations"] and not override_skip_animations: times = [run_time] else: step = 1 / self.renderer.camera.frame_rate @@ -658,9 +661,9 @@ def get_time_progression( time_progression = ProgressDisplay( times, total=n_iterations, - leave=file_writer_config["leave_progress_bars"], + leave=config["leave_progress_bars"], ascii=True if platform.system() == "Windows" else None, - disable=not file_writer_config["progress_bar"], + disable=not config["progress_bar"], ) return time_progression @@ -866,7 +869,7 @@ def add_sound(self, sound_file, time_offset=0, gain=None, **kwargs): gain : """ - if file_writer_config["skip_animations"]: + if config["skip_animations"]: return time = self.time + time_offset self.renderer.file_writer.add_sound(sound_file, time, gain, **kwargs) diff --git a/manim/scene/scene_file_writer.py b/manim/scene/scene_file_writer.py index e116477153..321ab05678 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -12,8 +12,9 @@ from time import sleep import datetime from PIL import Image +from pathlib import Path -from .. import file_writer_config, logger, console +from .. import config, logger, console from ..constants import FFMPEG_BIN, GIF_FILE_EXTENSION from ..utils.config_ops import digest_config from ..utils.file_ops import guarantee_existence @@ -59,48 +60,54 @@ def init_output_directories(self, scene_name): files will be written to and read from (within MEDIA_DIR). If they don't already exist, they will be created. """ - module_directory = self.get_default_module_directory() + if config["dry_run"]: + return + + if config["input_file"]: + module_directory = config["input_file"].stem + else: + module_directory = "" default_name = self.get_default_scene_name(scene_name) - if file_writer_config["save_last_frame"] or file_writer_config["save_pngs"]: - if file_writer_config["media_dir"] != "": - if not file_writer_config["custom_folders"]: + if config["save_last_frame"] or config["save_pngs"]: + if config["media_dir"] != "": + if not config["custom_folders"]: image_dir = guarantee_existence( os.path.join( - file_writer_config["images_dir"], + config["images_dir"], module_directory, ) ) else: - image_dir = guarantee_existence(file_writer_config["images_dir"]) + image_dir = guarantee_existence(config["images_dir"]) self.image_file_path = os.path.join( image_dir, add_extension_if_not_present(default_name, ".png") ) - if file_writer_config["write_to_movie"]: - if file_writer_config["video_dir"]: - if not file_writer_config["custom_folders"]: + if config["write_to_movie"]: + if config["video_dir"]: + if not config["custom_folders"]: movie_dir = guarantee_existence( os.path.join( - file_writer_config["video_dir"], + config["video_dir"], module_directory, self.get_resolution_directory(), ) ) else: - movie_dir = guarantee_existence( - os.path.join(file_writer_config["video_dir"]) - ) + movie_dir = guarantee_existence(os.path.join(config["video_dir"])) + self.movie_file_path = os.path.join( movie_dir, add_extension_if_not_present( - default_name, file_writer_config["movie_file_extension"] + default_name, config["movie_file_extension"] ), ) - self.gif_file_path = os.path.join( - movie_dir, - add_extension_if_not_present(default_name, GIF_FILE_EXTENSION), - ) - if not file_writer_config["custom_folders"]: + if config["save_as_gif"]: + self.gif_file_path = os.path.join( + movie_dir, + add_extension_if_not_present(default_name, GIF_FILE_EXTENSION), + ) + if not config["custom_folders"]: self.partial_movie_directory = guarantee_existence( os.path.join( movie_dir, @@ -111,7 +118,7 @@ def init_output_directories(self, scene_name): else: self.partial_movie_directory = guarantee_existence( os.path.join( - file_writer_config["media_dir"], + config["media_dir"], "temp_files", "partial_movie_files", default_name, @@ -126,6 +133,8 @@ def add_partial_movie_file(self, hash_animation): hash_animation : str Hash of the animation. """ + if not hasattr(self, "partial_movie_directory"): + return # None has to be added to partial_movie_files to keep the right index with scene.num_plays. # i.e if an animation is skipped, scene.num_plays is still incremented and we add an element to partial_movie_file be even with num_plays. @@ -136,25 +145,11 @@ def add_partial_movie_file(self, hash_animation): self.partial_movie_directory, "{}{}".format( hash_animation, - file_writer_config["movie_file_extension"], + config["movie_file_extension"], ), ) self.partial_movie_files.append(new_partial_movie_file) - def get_default_module_directory(self): - """ - This method gets the name of the directory containing - the file that has the Scene that is being rendered. - - Returns - ------- - str - The name of the directory. - """ - filename = os.path.basename(file_writer_config["input_file"]) - root, _ = os.path.splitext(filename) - return root - def get_default_scene_name(self, scene_name): """ This method returns the default scene name @@ -167,8 +162,8 @@ def get_default_scene_name(self, scene_name): str The default scene name. """ - fn = file_writer_config["output_file"] - return fn if fn else scene_name + fn = config["output_file"] + return fn if fn else Path(scene_name) def get_resolution_directory(self): """Get the name of the resolution directory directly containing @@ -195,8 +190,8 @@ def get_resolution_directory(self): :class:`str` The name of the directory. """ - pixel_height = self.video_quality_config["pixel_height"] - frame_rate = self.video_quality_config["frame_rate"] + pixel_height = config["pixel_height"] + frame_rate = config["frame_rate"] return "{}p{}".format(pixel_height, frame_rate) # Directory getters @@ -315,7 +310,7 @@ def begin_animation(self, allow_write=False): allow_write : bool, optional Whether or not to write to a video file. """ - if file_writer_config["write_to_movie"] and allow_write: + if config["write_to_movie"] and allow_write: self.open_movie_pipe() def end_animation(self, allow_write=False): @@ -328,7 +323,7 @@ def end_animation(self, allow_write=False): allow_write : bool, optional Whether or not to write to a video file. """ - if file_writer_config["write_to_movie"] and allow_write: + if config["write_to_movie"] and allow_write: self.close_movie_pipe() def write_frame(self, frame): @@ -341,9 +336,9 @@ def write_frame(self, frame): frame : np.array Pixel array of the frame. """ - if file_writer_config["write_to_movie"]: + if config["write_to_movie"]: self.writing_process.stdin.write(frame.tostring()) - if file_writer_config["save_pngs"]: + if config["save_pngs"]: path, extension = os.path.splitext(self.image_file_path) Image.fromarray(frame).save(f"{path}{self.frame_count}{extension}") self.frame_count += 1 @@ -374,7 +369,7 @@ def idle_stream(self): self.add_frame(*[frame] * n_frames) b = datetime.datetime.now() time_diff = (b - a).total_seconds() - frame_duration = 1 / self.video_quality_config["frame_rate"] + frame_duration = 1 / config["frame_rate"] if time_diff < frame_duration: sleep(frame_duration - time_diff) @@ -386,11 +381,11 @@ def finish(self): If save_last_frame is True, saves the last frame in the default image directory. """ - if file_writer_config["write_to_movie"]: + if config["write_to_movie"]: if hasattr(self, "writing_process"): self.writing_process.terminate() self.combine_movie_files() - if file_writer_config["flush_cache"]: + if config["flush_cache"]: self.flush_cache_directory() else: self.clean_cache() @@ -405,16 +400,14 @@ def open_movie_pipe(self): # TODO #486 Why does ffmpeg need temp files ? temp_file_path = ( - os.path.splitext(file_path)[0] - + "_temp" - + file_writer_config["movie_file_extension"] + os.path.splitext(file_path)[0] + "_temp" + config["movie_file_extension"] ) self.partial_movie_file_path = file_path self.temp_partial_movie_file_path = temp_file_path - fps = self.video_quality_config["frame_rate"] - height = self.video_quality_config["pixel_height"] - width = self.video_quality_config["pixel_width"] + fps = config["frame_rate"] + height = config["pixel_height"] + width = config["pixel_width"] command = [ FFMPEG_BIN, @@ -431,24 +424,12 @@ def open_movie_pipe(self): "-", # The imput comes from a pipe "-an", # Tells FFMPEG not to expect any audio "-loglevel", - file_writer_config["ffmpeg_loglevel"], + config["ffmpeg_loglevel"].lower(), ] - # TODO, the test for a transparent background should not be based on - # the file extension. - if file_writer_config["movie_file_extension"] == ".mov": - # This is if the background of the exported - # video should be transparent. - command += [ - "-vcodec", - "qtrle", - ] + if config["transparent"]: + command += ["-vcodec", "qtrle"] else: - command += [ - "-vcodec", - "libx264", - "-pix_fmt", - "yuv420p", - ] + command += ["-vcodec", "libx264", "-pix_fmt", "yuv420p"] command += [temp_file_path] self.writing_process = subprocess.Popen(command, stdin=subprocess.PIPE) @@ -482,9 +463,11 @@ def is_already_cached(self, hash_invocation): :class:`bool` Whether the file exists. """ + if not hasattr(self, "partial_movie_directory"): + return False path = os.path.join( self.partial_movie_directory, - "{}{}".format(hash_invocation, self.movie_file_extension), + "{}{}".format(hash_invocation, config["movie_file_extension"]), ) return os.path.exists(path) @@ -494,20 +477,20 @@ def combine_movie_files(self): partial movie files that make up a Scene into a single video file for that Scene. """ - # Manim renders the scene as many smaller movie files - # which are then concatenated to a larger one. The reason - # for this is that sometimes video-editing is made easier when - # one works with the broken up scene, which effectively has - # cuts at all the places you might want. But for viewing - # the scene as a whole, one of course wants to see it as a + # Manim renders the scene as many smaller movie files which are then + # concatenated to a larger one. The reason for this is that sometimes + # video-editing is made easier when one works with the broken up scene, + # which effectively has cuts at all the places you might want. But for + # viewing the scene as a whole, one of course wants to see it as a # single piece. partial_movie_files = [el for el in self.partial_movie_files if el is not None] - # NOTE : Here we should do a check and raise an exeption if partial movie file is empty. - # We can't, as a lot of stuff (in particular, in tests) use scene initialization, and this error would be raised as it's just - # an empty scene initialized. + # NOTE : Here we should do a check and raise an exeption if partial + # movie file is empty. We can't, as a lot of stuff (in particular, in + # tests) use scene initialization, and this error would be raised as + # it's just an empty scene initialized. - # Write a file partial_file_list.txt containing all - # partial movie files. This is used by FFMPEG. + # Write a file partial_file_list.txt containing all partial movie + # files. This is used by FFMPEG. file_list = os.path.join( self.partial_movie_directory, "partial_movie_file_list.txt" ) @@ -532,13 +515,13 @@ def combine_movie_files(self): "-i", file_list, "-loglevel", - file_writer_config["ffmpeg_loglevel"], + config["ffmpeg_loglevel"].lower(), ] - if self.write_to_movie and not self.save_as_gif: + if config["write_to_movie"] and not config["save_as_gif"]: commands += ["-c", "copy", movie_file_path] - if self.save_as_gif: + if config["save_as_gif"]: commands += [self.gif_file_path] if not self.includes_sound: @@ -549,7 +532,7 @@ def combine_movie_files(self): if self.includes_sound: sound_file_path = movie_file_path.replace( - file_writer_config["movie_file_extension"], ".wav" + config["movie_file_extension"], ".wav" ) # Makes sure sound file length will match video file self.add_audio_segment(AudioSegment.silent(0)) @@ -578,7 +561,7 @@ def combine_movie_files(self): "-map", "1:a:0", "-loglevel", - file_writer_config["ffmpeg_loglevel"], + config["ffmpeg_loglevel"].lower(), # "-shortest", temp_file_path, ] @@ -587,9 +570,9 @@ def combine_movie_files(self): os.remove(sound_file_path) self.print_file_ready_message( - self.gif_file_path if self.save_as_gif else movie_file_path + self.gif_file_path if config["save_as_gif"] else movie_file_path ) - if file_writer_config["write_to_movie"]: + if config["write_to_movie"]: for file_path in partial_movie_files: # We have to modify the accessed time so if we have to clean the cache we remove the one used the longest. modify_atime(file_path) @@ -601,9 +584,9 @@ def clean_cache(self): for file_name in os.listdir(self.partial_movie_directory) if file_name != "partial_movie_file_list.txt" ] - if len(cached_partial_movies) > file_writer_config["max_files_cached"]: + if len(cached_partial_movies) > config["max_files_cached"]: number_files_to_delete = ( - len(cached_partial_movies) - file_writer_config["max_files_cached"] + len(cached_partial_movies) - config["max_files_cached"] ) oldest_files_to_delete = sorted( [partial_movie_file for partial_movie_file in cached_partial_movies], @@ -613,7 +596,7 @@ def clean_cache(self): for file_to_delete in oldest_files_to_delete: os.remove(file_to_delete) logger.info( - f"The partial movie directory is full (> {file_writer_config['max_files_cached']} files). Therefore, manim has removed {number_files_to_delete} file(s) used by it the longest ago." + f"The partial movie directory is full (> {config['max_files_cached']} files). Therefore, manim has removed {number_files_to_delete} file(s) used by it the longest ago." + "You can change this behaviour by changing max_files_cached in config." ) diff --git a/manim/utils/caching.py b/manim/utils/caching.py index 2cc4bac4e4..6e864125ea 100644 --- a/manim/utils/caching.py +++ b/manim/utils/caching.py @@ -1,4 +1,4 @@ -from .. import file_writer_config, logger +from .. import config, logger from ..utils.hashing import get_hash_from_play_call, get_hash_from_wait_call from ..constants import DEFAULT_WAIT_TIME @@ -23,7 +23,7 @@ def wrapper(self, scene, *args, **kwargs): self.update_skipping_status() animations = scene.compile_play_args_to_animation_list(*args, **kwargs) scene.add_mobjects_from_animations(animations) - if file_writer_config["skip_animations"]: + if config["skip_animations"]: logger.debug(f"Skipping animation {self.num_plays}") func(self, scene, *args, **kwargs) # If the animation is skipped, we mark its hash as None. @@ -31,7 +31,7 @@ def wrapper(self, scene, *args, **kwargs): self.animations_hashes.append(None) self.file_writer.add_partial_movie_file(None) return - if not file_writer_config["disable_caching"]: + if not config["disable_caching"]: mobjects_on_scene = scene.get_mobjects() hash_play = get_hash_from_play_call( self, self.camera, animations, mobjects_on_scene @@ -41,7 +41,7 @@ def wrapper(self, scene, *args, **kwargs): f"Animation {self.num_plays} : Using cached data (hash : %(hash_play)s)", {"hash_play": hash_play}, ) - file_writer_config["skip_animations"] = True + config["skip_animations"] = True else: hash_play = "uncached_{:05}".format(self.num_plays) self.animations_hashes.append(hash_play) diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py index 856c59941d..b79e6c316d 100644 --- a/manim/utils/file_ops.py +++ b/manim/utils/file_ops.py @@ -11,16 +11,14 @@ import os import platform -import numpy as np import time -import re import subprocess as sp +from pathlib import Path def add_extension_if_not_present(file_name, extension): - # This could conceivably be smarter about handling existing differing extensions - if file_name[-len(extension) :] != extension: - return file_name + extension + if file_name.suffix != extension: + return file_name.with_suffix(extension) else: return file_name diff --git a/manim/utils/module_ops.py b/manim/utils/module_ops.py index f325d64794..1ef96befb2 100644 --- a/manim/utils/module_ops.py +++ b/manim/utils/module_ops.py @@ -1,6 +1,4 @@ -from .. import constants -from ..config import file_writer_config -from .. import logger, console +from .. import constants, logger, console, config import importlib.util import inspect import os @@ -30,9 +28,10 @@ def get_module(file_name): sys.exit(2) else: if os.path.exists(file_name): - if file_name[-3:] != ".py": + ext = file_name.suffix + if ext != ".py": raise ValueError(f"{file_name} is not a valid Manim python script.") - module_name = file_name[:-3].replace(os.sep, ".").split(".")[-1] + module_name = ext.replace(os.sep, ".").split(".")[-1] spec = importlib.util.spec_from_file_location(module_name, file_name) module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module @@ -63,10 +62,10 @@ def get_scenes_to_render(scene_classes): if not scene_classes: logger.error(constants.NO_SCENE_MESSAGE) return [] - if file_writer_config["write_all"]: + if config["write_all"]: return scene_classes result = [] - for scene_name in file_writer_config["scene_names"]: + for scene_name in config["scene_names"]: found = False for scene_class in scene_classes: if scene_class.__name__ == scene_name: diff --git a/manim/utils/tex_file_writing.py b/manim/utils/tex_file_writing.py index c47e65ab0c..71b080c95c 100644 --- a/manim/utils/tex_file_writing.py +++ b/manim/utils/tex_file_writing.py @@ -10,7 +10,7 @@ import hashlib from pathlib import Path -from .. import file_writer_config, config, logger +from .. import config, logger def tex_hash(expression): @@ -72,7 +72,7 @@ def generate_tex_file(expression, environment=None, tex_template=None): else: output = tex_template.get_texcode_for_expression(expression) - tex_dir = file_writer_config["tex_dir"] + tex_dir = config["tex_dir"] if not os.path.exists(tex_dir): os.makedirs(tex_dir) @@ -156,7 +156,7 @@ def compile_tex(tex_file, tex_compiler, output_format): result = tex_file.replace(".tex", output_format) result = Path(result).as_posix() tex_file = Path(tex_file).as_posix() - tex_dir = Path(file_writer_config["tex_dir"]).as_posix() + tex_dir = Path(config["tex_dir"]).as_posix() if not os.path.exists(result): command = tex_compilation_command( tex_compiler, output_format, tex_file, tex_dir diff --git a/tests/control_data/logs_data/BasicSceneLoggingTest.txt b/tests/control_data/logs_data/BasicSceneLoggingTest.txt index 01ab44b2ec..c30063bd12 100644 --- a/tests/control_data/logs_data/BasicSceneLoggingTest.txt +++ b/tests/control_data/logs_data/BasicSceneLoggingTest.txt @@ -1,4 +1,4 @@ -{"levelname": "INFO", "module": "main_utils", "message": "Log file will be saved in <>"} +{"levelname": "INFO", "module": "logger", "message": "Log file will be saved in <>"} {"levelname": "DEBUG", "module": "hashing", "message": "Hashing ..."} {"levelname": "DEBUG", "module": "hashing", "message": "Hashing done in <> s."} {"levelname": "DEBUG", "module": "hashing", "message": "Hash generated : <>"} diff --git a/tests/helpers/graphical_units.py b/tests/helpers/graphical_units.py index 332e49fe2f..773518748f 100644 --- a/tests/helpers/graphical_units.py +++ b/tests/helpers/graphical_units.py @@ -5,7 +5,7 @@ import tempfile import numpy as np -from manim import config, file_writer_config, logger +from manim import config, logger def set_test_scene(scene_object, module_name): @@ -27,17 +27,17 @@ def set_test_scene(scene_object, module_name): set_test_scene(DotTest, "geometry") """ - file_writer_config["skip_animations"] = True - file_writer_config["write_to_movie"] = False - file_writer_config["disable_caching"] = True + config["skip_animations"] = True + config["write_to_movie"] = False + config["disable_caching"] = True config["pixel_height"] = 480 config["pixel_width"] = 854 config["frame_rate"] = 15 with tempfile.TemporaryDirectory() as tmpdir: os.makedirs(os.path.join(tmpdir, "tex")) - file_writer_config["text_dir"] = os.path.join(tmpdir, "text") - file_writer_config["tex_dir"] = os.path.join(tmpdir, "tex") + config["text_dir"] = os.path.join(tmpdir, "text") + config["tex_dir"] = os.path.join(tmpdir, "tex") scene = scene_object() scene.render() data = scene.renderer.get_frame() diff --git a/tests/test_config.py b/tests/test_config.py index 36c60243bd..adeda7f249 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,20 +1,19 @@ -import pytest +import tempfile +from pathlib import Path import numpy as np -from manim import config, tempconfig + +from manim import config, tempconfig, Scene, Square, WHITE def test_tempconfig(): """Test the tempconfig context manager.""" original = config.copy() - with tempconfig({"frame_width": 100, "frame_height": 42, "foo": -1}): + with tempconfig({"frame_width": 100, "frame_height": 42}): # check that config was modified correctly assert config["frame_width"] == 100 assert config["frame_height"] == 42 - # 'foo' is not a key in the original dict so it shouldn't be added - assert "foo" not in config - # check that no keys are missing and no new keys were added assert set(original.keys()) == set(config.keys()) @@ -27,3 +26,75 @@ def test_tempconfig(): assert np.allclose(config[k], v) else: assert config[k] == v + + +class MyScene(Scene): + def construct(self): + self.add(Square()) + self.wait(1) + + +def test_transparent(): + """Test the 'transparent' config option.""" + orig_verbosity = config["verbosity"] + config["verbosity"] = "ERROR" + + with tempconfig({"dry_run": True}): + scene = MyScene() + scene.render() + frame = scene.renderer.get_frame() + assert np.allclose(frame[0, 0], [0, 0, 0, 255]) + + with tempconfig({"transparent": True, "dry_run": True}): + scene = MyScene() + scene.render() + frame = scene.renderer.get_frame() + assert np.allclose(frame[0, 0], [0, 0, 0, 0]) + + config["verbosity"] = orig_verbosity + + +def test_background_color(): + """Test the 'background_color' config option.""" + with tempconfig({"background_color": WHITE, "verbosity": "ERROR", "dry_run": True}): + scene = MyScene() + scene.render() + frame = scene.renderer.get_frame() + assert np.allclose(frame[0, 0], [255, 255, 255, 255]) + + +def test_digest_file(tmp_path): + """Test that a config file can be digested programatically.""" + assert config["media_dir"] == Path("media") + assert config["video_dir"] == Path("media/videos") + + with tempconfig({}): + tmp_cfg = tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False) + tmp_cfg.write( + """ + [CLI] + media_dir = this_is_my_favorite_path + video_dir = {media_dir}/videos + """ + ) + tmp_cfg.close() + config.digest_file(tmp_cfg.name) + + assert config["media_dir"] == Path("this_is_my_favorite_path") + assert config["video_dir"] == Path("this_is_my_favorite_path/videos") + + assert config["media_dir"] == Path("media") + assert config["video_dir"] == Path("media/videos") + + +def test_temporary_dry_run(): + """Test that tempconfig correctly restores after setting dry_run.""" + assert config["write_to_movie"] + assert not config["save_last_frame"] + + with tempconfig({"dry_run": True}): + assert not config["write_to_movie"] + assert not config["save_last_frame"] + + assert config["write_to_movie"] + assert not config["save_last_frame"] diff --git a/tests/test_container.py b/tests/test_container.py index 4bf8d27532..27aca4262d 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -1,5 +1,5 @@ import pytest -from manim import Container, Mobject, Scene, file_writer_config +from manim import Container, Mobject, Scene, tempconfig def test_ABC(): @@ -9,7 +9,9 @@ def test_ABC(): # The following should work without raising exceptions Mobject() - Scene() + + with tempconfig({"dry_run": True}): + Scene() def container_add(obj, get_submobjects): @@ -63,13 +65,10 @@ def test_mobject_remove(): container_remove(obj, lambda: obj.submobjects) -def test_scene_add(): - """Test Scene.add().""" - scene = Scene() - container_add(scene, lambda: scene.mobjects) - - -def test_scene_remove(): - """Test Scene.remove().""" - scene = Scene() - container_remove(scene, lambda: scene.mobjects) +def test_scene_add_remove(): + """Test Scene.add() and Scene.remove().""" + with tempconfig({"dry_run": True}): + scene = Scene() + container_add(scene, lambda: scene.mobjects) + scene = Scene() + container_remove(scene, lambda: scene.mobjects) diff --git a/tests/test_copy.py b/tests/test_copy.py index a24fb1d1da..4edd5e00a3 100644 --- a/tests/test_copy.py +++ b/tests/test_copy.py @@ -1,5 +1,5 @@ from pathlib import Path -from manim import Mobject, BraceLabel, file_writer_config +from manim import Mobject, BraceLabel, config def test_mobject_copy(): @@ -18,13 +18,13 @@ def test_mobject_copy(): def test_bracelabel_copy(tmp_path): """Test that a copy is a deepcopy.""" # For this test to work, we need to tweak some folders temporarily - original_text_dir = file_writer_config["text_dir"] - original_tex_dir = file_writer_config["tex_dir"] + original_text_dir = config["text_dir"] + original_tex_dir = config["tex_dir"] mediadir = Path(tmp_path) / "deepcopy" - file_writer_config["text_dir"] = str(mediadir.joinpath("Text")) - file_writer_config["tex_dir"] = str(mediadir.joinpath("Tex")) + config["text_dir"] = str(mediadir.joinpath("Text")) + config["tex_dir"] = str(mediadir.joinpath("Tex")) for el in ["text_dir", "tex_dir"]: - Path(file_writer_config[el]).mkdir(parents=True) + Path(config[el]).mkdir(parents=True) # Before the refactoring of Mobject.copy(), the class BraceLabel was the # only one to have a non-trivial definition of copy. Here we test that it @@ -43,5 +43,5 @@ def test_bracelabel_copy(tmp_path): assert copy.submobjects[0] is not orig.brace # Restore the original folders - file_writer_config["text_dir"] = original_text_dir - file_writer_config["tex_dir"] = original_tex_dir + config["text_dir"] = original_text_dir + config["tex_dir"] = original_tex_dir diff --git a/tests/test_logging/test_logging.py b/tests/test_logging/test_logging.py index f4ae349a34..504a76ecce 100644 --- a/tests/test_logging/test_logging.py +++ b/tests/test_logging/test_logging.py @@ -43,7 +43,7 @@ def test_logging_when_scene_is_not_specified(tmp_path, python_version): "-m", "manim", path_basic_scene, - "-l", + "-ql", "--log_to_file", "-v", "DEBUG", diff --git a/tests/test_cli_flags.py b/tests/test_quality_flags.py similarity index 52% rename from tests/test_cli_flags.py rename to tests/test_quality_flags.py index 242c728841..72752538c4 100644 --- a/tests/test_cli_flags.py +++ b/tests/test_quality_flags.py @@ -1,5 +1,6 @@ from manim import constants -from manim.config.main_utils import _determine_quality, parse_args +from manim.config.main_utils import parse_args +from manim.config.utils import determine_quality def test_quality_flags(): @@ -7,41 +8,40 @@ def test_quality_flags(): parsed = parse_args("manim dummy_filename".split()) assert parsed.quality == constants.DEFAULT_QUALITY_SHORT - assert _determine_quality(parsed) == constants.DEFAULT_QUALITY + assert determine_quality(parsed) == constants.DEFAULT_QUALITY for quality in constants.QUALITIES.keys(): + flag = constants.QUALITIES[quality]["flag"] # Assert that quality is properly set when using -q* - arguments = f"manim -q{constants.QUALITIES[quality]} dummy_filename".split() + arguments = f"manim -q{flag} dummy_filename".split() parsed = parse_args(arguments) - assert parsed.quality == constants.QUALITIES[quality] - assert quality == _determine_quality(parsed) + assert parsed.quality == flag + assert quality == determine_quality(parsed) # Assert that quality is properly set when using -q * - arguments = f"manim -q {constants.QUALITIES[quality]} dummy_filename".split() + arguments = f"manim -q {flag} dummy_filename".split() parsed = parse_args(arguments) - assert parsed.quality == constants.QUALITIES[quality] - assert quality == _determine_quality(parsed) + assert parsed.quality == flag + assert quality == determine_quality(parsed) # Assert that quality is properly set when using --quality * - arguments = ( - f"manim --quality {constants.QUALITIES[quality]} dummy_filename".split() - ) + arguments = f"manim --quality {flag} dummy_filename".split() parsed = parse_args(arguments) - assert parsed.quality == constants.QUALITIES[quality] - assert quality == _determine_quality(parsed) + assert parsed.quality == flag + assert quality == determine_quality(parsed) # Assert that quality is properly set when using -*_quality arguments = f"manim --{quality} dummy_filename".split() parsed = parse_args(arguments) assert getattr(parsed, quality) - assert quality == _determine_quality(parsed) + assert quality == determine_quality(parsed) # Assert that *_quality is False when not specifying it parsed = parse_args("manim dummy_filename".split()) assert not getattr(parsed, quality) - assert _determine_quality(parsed) == constants.DEFAULT_QUALITY + assert determine_quality(parsed) == constants.DEFAULT_QUALITY diff --git a/tests/test_scene_rendering/conftest.py b/tests/test_scene_rendering/conftest.py index 0dc51dce18..b5b6f1d052 100644 --- a/tests/test_scene_rendering/conftest.py +++ b/tests/test_scene_rendering/conftest.py @@ -2,8 +2,6 @@ from pathlib import Path -from manim import file_writer_config - @pytest.fixture def manim_cfg_file(): diff --git a/tests/test_scene_rendering/simple_scenes.py b/tests/test_scene_rendering/simple_scenes.py index b05c27575f..6522d4cf60 100644 --- a/tests/test_scene_rendering/simple_scenes.py +++ b/tests/test_scene_rendering/simple_scenes.py @@ -27,3 +27,10 @@ def construct(self): self.wait(1) self.play(ShowCreation(Square().shift(3 * DOWN))) self.wait(1) + + +class NoAnimations(Scene): + def construct(self): + dot = Dot().set_color(GREEN) + self.add(dot) + self.wait(1) diff --git a/tests/test_scene_rendering/test_caching_related.py b/tests/test_scene_rendering/test_caching_related.py index 3f6d4ec973..03395571ab 100644 --- a/tests/test_scene_rendering/test_caching_related.py +++ b/tests/test_scene_rendering/test_caching_related.py @@ -1,7 +1,6 @@ import os import pytest import subprocess -from manim import file_writer_config from ..utils.commands import capture from ..utils.video_tester import * diff --git a/tests/test_scene_rendering/test_cli_flags.py b/tests/test_scene_rendering/test_cli_flags.py index aa815223ac..934445f133 100644 --- a/tests/test_scene_rendering/test_cli_flags.py +++ b/tests/test_scene_rendering/test_cli_flags.py @@ -1,4 +1,5 @@ import pytest +from pathlib import Path from ..utils.video_tester import * @@ -61,3 +62,51 @@ def test_n_flag(tmp_path, simple_scenes_path): ] _, err, exit_code = capture(command) assert exit_code == 0, err + + +def test_s_flag_no_animations(tmp_path, manim_cfg_file, simple_scenes_path): + scene_name = "NoAnimations" + command = [ + "python", + "-m", + "manim", + simple_scenes_path, + scene_name, + "-ql", + "-s", + "--media_dir", + str(tmp_path), + ] + out, err, exit_code = capture(command) + assert exit_code == 0, err + + print(list((tmp_path / "images" / "simple_scenes").iterdir())) + + is_empty = not any((tmp_path / "videos").iterdir()) + assert is_empty, "running manim with -s flag rendered a video" + + is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) + assert not is_empty, "running manim with -s flag did not render an image" + + +def test_s_flag(tmp_path, manim_cfg_file, simple_scenes_path): + scene_name = "SquareToCircle" + command = [ + "python", + "-m", + "manim", + simple_scenes_path, + scene_name, + "-ql", + "-s", + "--media_dir", + str(tmp_path), + ] + out, err, exit_code = capture(command) + assert exit_code == 0, err + + is_empty = not any((tmp_path / "videos").iterdir()) + assert is_empty, "running manim with -s flag rendered a video" + + is_empty = not any((tmp_path / "images" / "simple_scenes").iterdir()) + assert not is_empty, "running manim with -s flag did not render an image" diff --git a/tests/utils/GraphicalUnitTester.py b/tests/utils/GraphicalUnitTester.py index a4019e4f94..8b46bffb55 100644 --- a/tests/utils/GraphicalUnitTester.py +++ b/tests/utils/GraphicalUnitTester.py @@ -2,7 +2,7 @@ import logging import numpy as np -from manim import config, file_writer_config +from manim import config, tempconfig class GraphicalUnitTester: @@ -47,31 +47,26 @@ def __init__( tests_directory, "control_data", "graphical_units_data", module_tested ) - # IMPORTANT NOTE : The graphical units tests don't use for now any custom manim.cfg, - # since it is impossible to manually select a manim.cfg from a python file. (see issue #293) - file_writer_config["text_dir"] = os.path.join( - self.path_tests_medias_cache, "Text" - ) - file_writer_config["tex_dir"] = os.path.join( - self.path_tests_medias_cache, "Tex" - ) + # IMPORTANT NOTE : The graphical units tests don't use for now any + # custom manim.cfg, since it is impossible to manually select a + # manim.cfg from a python file. (see issue #293) + config["text_dir"] = os.path.join(self.path_tests_medias_cache, "Text") + config["tex_dir"] = os.path.join(self.path_tests_medias_cache, "Tex") - file_writer_config["skip_animations"] = True - file_writer_config["write_to_movie"] = False - file_writer_config["disable_caching"] = True - config["pixel_height"] = 480 - config["pixel_width"] = 854 - config["frame_rate"] = 15 + config["skip_animations"] = True + config["disable_caching"] = True + config["quality"] = "low_quality" for dir_temp in [ self.path_tests_medias_cache, - file_writer_config["text_dir"], - file_writer_config["tex_dir"], + config["text_dir"], + config["tex_dir"], ]: os.makedirs(dir_temp) - self.scene = scene_object() - self.scene.render() + with tempconfig({"dry_run": True}): + self.scene = scene_object() + self.scene.render() def _load_data(self): """Load the np.array of the last frame of a pre-rendered scene. If not found, throw FileNotFoundError.