diff --git a/manim/default.cfg b/manim/default.cfg index d978e31b93..a5006bd2f7 100644 --- a/manim/default.cfg +++ b/manim/default.cfg @@ -97,6 +97,12 @@ frame_rate = 60 pixel_height = 1440 pixel_width = 2560 +# Use -1 to set max_files_cached to infinity. +max_files_cached = 100 +#Flush cache will delete all the cached partial-movie-files. +flush_cache = False +disable_caching = False + # These override the previous by using -t, --transparent [transparent] png_mode = RGBA diff --git a/manim/scene/scene.py b/manim/scene/scene.py index 70ba5bc4ee..dd19d1ac71 100644 --- a/manim/scene/scene.py +++ b/manim/scene/scene.py @@ -2,6 +2,7 @@ import random import warnings import platform +import copy from tqdm import tqdm as ProgressDisplay import numpy as np @@ -16,6 +17,7 @@ from ..mobject.mobject import Mobject from ..scene.scene_file_writer import SceneFileWriter from ..utils.iterables import list_update +from ..utils.hashing import get_hash_from_play_call, get_hash_from_wait_call class Scene(Container): @@ -55,7 +57,7 @@ def __init__(self, **kwargs): Container.__init__(self, **kwargs) self.camera = self.camera_class(**camera_config) self.file_writer = SceneFileWriter(self, **file_writer_config,) - + self.play_hashes_list = [] self.mobjects = [] # TODO, remove need for foreground mobjects self.foreground_mobjects = [] @@ -72,6 +74,9 @@ def __init__(self, **kwargs): except EndSceneEarlyException: pass self.tear_down() + # We have to reset these settings in case of multiple renders. + file_writer_config["skip_animations"] = False + self.original_skipping_status = file_writer_config["skip_animations"] self.file_writer.finish() self.print_end_message() @@ -373,6 +378,17 @@ def add_mobjects_among(self, values): self.add(*filter(lambda m: isinstance(m, Mobject), values)) return self + def add_mobjects_from_animations(self, animations): + + curr_mobjects = self.get_mobject_family_members() + for animation in animations: + # Anything animated that's not already in the + # scene gets added to the scene + mob = animation.mobject + if mob not in curr_mobjects: + self.add(mob) + curr_mobjects += mob.get_family() + def remove(self, *mobjects): """ Removes mobjects in the passed list of mobjects @@ -832,6 +848,71 @@ def update_skipping_status(self): file_writer_config["skip_animations"] = True raise EndSceneEarlyException() + def handle_caching_play(func): + """ + Decorator that returns a wrapped version of func that will compute the hash of the play invocation. + + The returned function will act according to the computed hash: either skip the animation because it's already cached, or let the invoked function play normally. + + Parameters + ---------- + func : Callable[[...], None] + The play like function that has to be written to the video file stream. Take the same parameters as `scene.play`. + """ + + def wrapper(self, *args, **kwargs): + self.revert_to_original_skipping_status() + animations = self.compile_play_args_to_animation_list(*args, **kwargs) + self.add_mobjects_from_animations(animations) + if not file_writer_config["disable_caching"]: + mobjects_on_scene = self.get_mobjects() + hash_play = get_hash_from_play_call( + self.camera, animations, mobjects_on_scene + ) + self.play_hashes_list.append(hash_play) + if self.file_writer.is_already_cached(hash_play): + logger.info( + f"Animation {self.num_plays} : Using cached data (hash : {hash_play})" + ) + file_writer_config["skip_animations"] = True + else: + hash_play = "uncached_{:05}".format(self.num_plays) + self.play_hashes_list.append(hash_play) + func(self, *args, **kwargs) + + return wrapper + + def handle_caching_wait(func): + """ + Decorator that returns a wrapped version of func that will compute the hash of the wait invocation. + + The returned function will act according to the computed hash: either skip the animation because it's already cached, or let the invoked function play normally. + + Parameters + ---------- + func : Callable[[...], None] + The wait like function that has to be written to the video file stream. Take the same parameters as `scene.wait`. + """ + + def wrapper(self, duration=DEFAULT_WAIT_TIME, stop_condition=None): + self.revert_to_original_skipping_status() + if not file_writer_config["disable_caching"]: + hash_wait = get_hash_from_wait_call( + self.camera, duration, stop_condition, self.get_mobjects() + ) + self.play_hashes_list.append(hash_wait) + if self.file_writer.is_already_cached(hash_wait): + logger.info( + f"Wait {self.num_plays} : Using cached data (hash : {hash_wait})" + ) + file_writer_config["skip_animations"] = True + else: + hash_wait = "uncached_{:05}".format(self.num_plays) + self.play_hashes_list.append(hash_wait) + func(self, duration, stop_condition) + + return wrapper + def handle_play_like_call(func): """ This method is used internally to wrap the @@ -875,16 +956,9 @@ def begin_animations(self, animations): List of involved animations. """ - curr_mobjects = self.get_mobject_family_members() for animation in animations: # Begin animation animation.begin() - # Anything animated that's not already in the - # scene gets added to the scene - mob = animation.mobject - if mob not in curr_mobjects: - self.add(mob) - curr_mobjects += mob.get_family() def progress_through_animations(self, animations): """ @@ -933,6 +1007,7 @@ def finish_animations(self, animations): else: self.update_mobjects(0) + @handle_caching_play @handle_play_like_call def play(self, *args, **kwargs): """ @@ -1032,6 +1107,7 @@ def get_wait_time_progression(self, duration, stop_condition): time_progression.set_description("Waiting {}".format(self.num_plays)) return time_progression + @handle_caching_wait @handle_play_like_call def wait(self, duration=DEFAULT_WAIT_TIME, stop_condition=None): """ @@ -1105,8 +1181,8 @@ def force_skipping(self): Scene The Scene, with skipping turned on. """ - self.original_skipping_status = self.SKIP_ANIMATIONS - self.SKIP_ANIMATIONS = True + self.original_skipping_status = file_writer_config["skip_animations"] + file_writer_config["skip_animations"] = True return self def revert_to_original_skipping_status(self): @@ -1121,7 +1197,7 @@ def revert_to_original_skipping_status(self): The Scene, with the original skipping status. """ if hasattr(self, "original_skipping_status"): - self.SKIP_ANIMATIONS = self.original_skipping_status + file_writer_config["skip_animations"] = self.original_skipping_status return self def add_frames(self, *frames): @@ -1156,7 +1232,7 @@ def add_sound(self, sound_file, time_offset=0, gain=None, **kwargs): gain : """ - if self.SKIP_ANIMATIONS: + if file_writer_config["skip_animations"]: return time = self.get_time() + time_offset self.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 ad5512e1f9..4edbb1b674 100644 --- a/manim/scene/scene_file_writer.py +++ b/manim/scene/scene_file_writer.py @@ -14,7 +14,7 @@ from ..utils.config_ops import digest_config from ..utils.file_ops import guarantee_existence from ..utils.file_ops import add_extension_if_not_present -from ..utils.file_ops import get_sorted_integer_files +from ..utils.file_ops import modify_atime from ..utils.sounds import get_full_sound_file_path @@ -170,8 +170,9 @@ def get_next_partial_movie_path(self): """ result = os.path.join( self.partial_movie_directory, - "{:05}{}".format( - self.scene.num_plays, file_writer_config["movie_file_extension"], + "{}{}".format( + self.scene.play_hashes_list[self.scene.num_plays], + file_writer_config["movie_file_extension"], ), ) return result @@ -351,6 +352,10 @@ def finish(self): if hasattr(self, "writing_process"): self.writing_process.terminate() self.combine_movie_files() + if file_writer_config["flush_cache"]: + self.flush_cache_directory() + else: + self.clean_cache() if file_writer_config["save_last_frame"]: self.scene.update_frame(ignore_skipping=True) self.save_final_image(self.scene.get_image()) @@ -421,6 +426,28 @@ def close_movie_pipe(self): shutil.move( self.temp_partial_movie_file_path, self.partial_movie_file_path, ) + logger.debug( + f"Animation {self.scene.num_plays} : Partial movie file written in {self.partial_movie_file_path}" + ) + + def is_already_cached(self, hash_invocation): + """Will check if a file named with `hash_invocation` exists. + + Parameters + ---------- + hash_invocation : :class:`str` + The hash corresponding to an invocation to either `scene.play` or `scene.wait`. + + Returns + ------- + :class:`bool` + Whether the file exists. + """ + path = os.path.join( + self.partial_movie_directory, + "{}{}".format(hash_invocation, self.movie_file_extension), + ) + return os.path.exists(path) def combine_movie_files(self): """ @@ -435,34 +462,27 @@ def combine_movie_files(self): # 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. - kwargs = { - "remove_non_integer_files": True, - "extension": file_writer_config["movie_file_extension"], - } - if file_writer_config["from_animation_number"] is not None: - kwargs["min_index"] = file_writer_config["from_animation_number"] - if file_writer_config["upto_animation_number"] not in [None, np.inf]: - kwargs["max_index"] = file_writer_config["upto_animation_number"] - else: - kwargs["remove_indices_greater_than"] = self.scene.num_plays - 1 - partial_movie_files = get_sorted_integer_files( - self.partial_movie_directory, **kwargs - ) + partial_movie_files = [ + os.path.join( + self.partial_movie_directory, + "{}{}".format(hash_play, file_writer_config["movie_file_extension"]), + ) + for hash_play in self.scene.play_hashes_list + ] if len(partial_movie_files) == 0: logger.error("No animations in this scene") return - # Write a file partial_file_list.txt containing all - # partial movie files + # partial movie files. This is used by FFMPEG. file_list = os.path.join( self.partial_movie_directory, "partial_movie_file_list.txt" ) with open(file_list, "w") as fp: + fp.write("# This file is used internally by FFMPEG.\n") for pf_path in partial_movie_files: if os.name == "nt": pf_path = pf_path.replace("\\", "/") fp.write("file 'file:{}'\n".format(pf_path)) - movie_file_path = self.get_movie_file_path() commands = [ FFMPEG_BIN, @@ -527,6 +547,46 @@ def combine_movie_files(self): os.remove(sound_file_path) self.print_file_ready_message(movie_file_path) + if file_writer_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) + + def clean_cache(self): + """Will clean the cache by removing the partial_movie_files used by manim the longest ago.""" + cached_partial_movies = [ + os.path.join(self.partial_movie_directory, file_name) + 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"]: + number_files_to_delete = ( + len(cached_partial_movies) - file_writer_config["max_files_cached"] + ) + oldest_files_to_delete = sorted( + [partial_movie_file for partial_movie_file in cached_partial_movies], + key=os.path.getatime, + )[:number_files_to_delete] + # oldest_file_path = min(cached_partial_movies, key=os.path.getatime) + 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." + + "You can change this behaviour by changing max_files_cached in config." + ) + + def flush_cache_directory(self): + """Delete all the cached partial movie files""" + cached_partial_movies = [ + os.path.join(self.partial_movie_directory, file_name) + for file_name in os.listdir(self.partial_movie_directory) + if file_name != "partial_movie_file_list.txt" + ] + for f in cached_partial_movies: + os.remove(f) + logger.info( + f"Cache flushed. {len(cached_partial_movies)} file(s) deleted in {self.partial_movie_directory}." + ) def print_file_ready_message(self, file_path): """ diff --git a/manim/utils/config_utils.py b/manim/utils/config_utils.py index 40997854ea..7180e02901 100644 --- a/manim/utils/config_utils.py +++ b/manim/utils/config_utils.py @@ -59,8 +59,11 @@ def _parse_file_writer_config(config_parser, args): "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 @@ -112,7 +115,8 @@ def _parse_file_writer_config(config_parser, args): "write_all", ]: fw_config[opt] = config_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: config_parser["streaming"][opt] @@ -133,7 +137,9 @@ def _parse_file_writer_config(config_parser, args): fw_config["skip_animations"] = any( [fw_config["save_last_frame"], fw_config["from_animation_number"]] ) - + 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 verbose flag to read in the log level verbose = getattr(args, "verbose") verbose = default["verbose"] if verbose is None else verbose @@ -269,14 +275,24 @@ def _parse_cli(arg_list, input=True): const=True, help="Save the video as gif", ) - + parser.add_argument( + "--disable_caching", + action="store_const", + const=True, + help="Disable caching (will generate partial-movie-files anyway).", + ) + parser.add_argument( + "--flush_cache", + action="store_const", + const=True, + help="Remove all cached partial-movie-files.", + ) parser.add_argument( "--log_to_file", action="store_const", const=True, help="Log terminal output to file.", ) - # The default value of the following is set in manim.cfg parser.add_argument( "-c", "--color", help="Background color", diff --git a/manim/utils/file_ops.py b/manim/utils/file_ops.py index 2e2dad80a3..2f975704b6 100644 --- a/manim/utils/file_ops.py +++ b/manim/utils/file_ops.py @@ -2,6 +2,7 @@ import subprocess as sp import platform import numpy as np +import time def add_extension_if_not_present(file_name, extension): @@ -30,36 +31,15 @@ def seek_full_path_from_defaults(file_name, default_dir, extensions): raise IOError("File {} not Found".format(file_name)) -def get_sorted_integer_files( - directory, - min_index=0, - max_index=np.inf, - remove_non_integer_files=False, - remove_indices_greater_than=None, - extension=None, -): - indexed_files = [] - for file in os.listdir(directory): - if "." in file: - index_str = file[: file.index(".")] - else: - index_str = file +def modify_atime(file_path): + """Will manually change the accessed time (called `atime`) of the file, as on a lot of OS the accessed time refresh is disabled by default. - full_path = os.path.join(directory, file) - if index_str.isdigit(): - index = int(index_str) - if remove_indices_greater_than is not None: - if index > remove_indices_greater_than: - os.remove(full_path) - continue - if extension is not None and not file.endswith(extension): - continue - if index >= min_index and index < max_index: - indexed_files.append((index, file)) - elif remove_non_integer_files: - os.remove(full_path) - indexed_files.sort(key=lambda p: p[0]) - return list(map(lambda p: os.path.join(directory, p[1]), indexed_files)) + Parameters + ---------- + file_path : :class:`str` + The path of the file. + """ + os.utime(file_path, times=(time.time(), os.path.getmtime(file_path))) def open_file(file_path): diff --git a/manim/utils/hashing.py b/manim/utils/hashing.py new file mode 100644 index 0000000000..563dd330e0 --- /dev/null +++ b/manim/utils/hashing.py @@ -0,0 +1,191 @@ +import json +import zlib +import inspect +import copy +import dis +import numpy as np +from types import ModuleType + +from ..logger import logger + + +class CustomEncoder(json.JSONEncoder): + def default(self, obj): + """ + This method is used to serialize objects to JSON format. + + If obj is a function, then it will return a dict with two keys : 'code', for the code source, and 'nonlocals' for all nonlocalsvalues. (including nonlocals functions, that will be serialized as this is recursive.) + if obj is a np.darray, it converts it into a list. + if obj is an object with __dict__ attribute, it returns its __dict__. + Else, will let the JSONEncoder do the stuff, and throw an error if the type is not suitable for JSONEncoder. + + Parameters + ---------- + obj : Any + Arbitrary object to convert + + Returns + ------- + Any + Python object that JSON encoder will recognize + + """ + if inspect.isfunction(obj) and not isinstance(obj, ModuleType): + cvars = inspect.getclosurevars(obj) + cvardict = {**copy.copy(cvars.globals), **copy.copy(cvars.nonlocals)} + for i in list(cvardict): + # NOTE : All module types objects are removed, because otherwise it throws ValueError: Circular reference detected if not. TODO + if isinstance(cvardict[i], ModuleType): + del cvardict[i] + return {"code": inspect.getsource(obj), "nonlocals": cvardict} + elif isinstance(obj, np.ndarray): + return list(obj) + elif hasattr(obj, "__dict__"): + temp = getattr(obj, "__dict__") + return self._encode_dict(temp) + elif isinstance(obj, np.uint8): + return int(obj) + try: + return json.JSONEncoder.default(self, obj) + except TypeError: + # This is used when the user enters an unknown type in CONFIG. Rather than throwing an error, we transform + # it into a string "Unsupported type for hashing" so that it won't affect the hash. + return "Unsupported type for hashing" + + def _encode_dict(self, obj): + """Clean dicts to be serialized : As dict keys must be of the type (str, int, float, bool), we have to change them when they are not of the right type. + To do that, if one is not of the good type we turn it into its hash using the same + method as all the objects here. + + Parameters + ---------- + obj : Any + The obj to be cleaned. + + Returns + ------- + Any + The object cleaned following the processus above. + """ + + def key_to_hash(key): + if not isinstance(key, (str, int, float, bool)) and key is not None: + # print('called') + return zlib.crc32(json.dumps(key, cls=CustomEncoder).encode()) + return key + + if isinstance(obj, dict): + return {key_to_hash(k): self._encode_dict(v) for k, v in obj.items()} + return obj + + def encode(self, obj): + return super().encode(self._encode_dict(obj)) + + +def get_json(obj): + """Recursively serialize `object` to JSON using the :class:`CustomEncoder` class. + + Paramaters + ---------- + dict_config : :class:`dict` + The dict to flatten + + Returns + ------- + :class:`str` + The flattened object + """ + return json.dumps(obj, cls=CustomEncoder) + + +def get_camera_dict_for_hashing(camera_object): + """Remove some keys from `camera_object.__dict__` that are very heavy and useless for the caching functionality. + + Parameters + ---------- + camera_object : :class:`~.Camera` + The camera object used in the scene + + Returns + ------- + :class:`dict` + `Camera.__dict__` but cleaned. + """ + camera_object_dict = copy.copy(camera_object.__dict__) + # We have to clean a little bit of camera_dict, as pixel_array and background are two very big numpy arrays. + # They are not essential to caching process. + # We also have to remove pixel_array_to_cairo_context as it contains used memory adress (set randomly). See l.516 get_cached_cairo_context in camera.py + for to_clean in ["background", "pixel_array", "pixel_array_to_cairo_context"]: + camera_object_dict.pop(to_clean, None) + return camera_object_dict + + +def get_hash_from_play_call(camera_object, animations_list, current_mobjects_list): + """Take the list of animations and a list of mobjects and output their hashes. This is meant to be used for `scene.play` function. + + Parameters + ----------- + camera_object : :class:`~.Camera` + The camera object used in the scene. + + animations_list : Iterable[:class:`~.Animation`] + The list of animations. + + current_mobjects_list : Iterable[:class:`~.Mobject`] + The list of mobjects. + + Returns + ------- + :class:`str` + A string concatenation of the respective hashes of `camera_object`, `animations_list` and `current_mobjects_list`, separated by `_`. + """ + camera_json = get_json(get_camera_dict_for_hashing(camera_object)) + animations_list_json = [ + get_json(x) for x in sorted(animations_list, key=lambda obj: str(obj)) + ] + current_mobjects_list_json = [ + get_json(x) for x in sorted(current_mobjects_list, key=lambda obj: str(obj)) + ] + hash_camera, hash_animations, hash_current_mobjects = [ + zlib.crc32(repr(json_val).encode()) + for json_val in [camera_json, animations_list_json, current_mobjects_list_json] + ] + return "{}_{}_{}".format(hash_camera, hash_animations, hash_current_mobjects) + + +def get_hash_from_wait_call( + camera_object, wait_time, stop_condition_function, current_mobjects_list +): + """Take a wait time, a boolean function as a stop condition and a list of mobjects, and then output their individual hashes. This is meant to be used for `scene.wait` function. + + Parameters + ----------- + wait_time : :class:`float` + The time to wait + + stop_condition_function : Callable[[...], bool] + Boolean function used as a stop_condition in `wait`. + + Returns + ------- + :class:`str` + A concatenation of the respective hashes of `animations_list and `current_mobjects_list`, separated by `_`. + """ + camera_json = get_json(get_camera_dict_for_hashing(camera_object)) + current_mobjects_list_json = [ + get_json(x) for x in sorted(current_mobjects_list, key=lambda obj: str(obj)) + ] + hash_current_mobjects = zlib.crc32(repr(current_mobjects_list_json).encode()) + hash_camera = zlib.crc32(repr(camera_json).encode()) + if stop_condition_function is not None: + hash_function = zlib.crc32(get_json(stop_condition_function).encode()) + return "{}_{}{}_{}".format( + hash_camera, + str(wait_time).replace(".", "-"), + hash_function, + hash_current_mobjects, + ) + else: + return "{}_{}_{}".format( + hash_camera, str(wait_time).replace(".", "-"), hash_current_mobjects + ) diff --git a/tests/conftest.py b/tests/conftest.py index 95f158842c..3236483e55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import os import sys import logging +from shutil import rmtree def pytest_addoption(parser): @@ -44,3 +45,10 @@ def reset_cfg_file(): yield with open(cfgfilepath, "w") as cfgfile: cfgfile.write(original) + + +@pytest.fixture +def clean_tests_cache(): + yield + path_output = os.path.join("tests", "tests_cache", "media_temp") + rmtree(path_output) diff --git a/tests/test_cli/test_cfg_subcmd.py b/tests/test_cli/test_cfg_subcmd.py index 2643ca2532..e994febc07 100644 --- a/tests/test_cli/test_cfg_subcmd.py +++ b/tests/test_cli/test_cfg_subcmd.py @@ -52,7 +52,6 @@ def test_cfg_write(python_version): ) assert ( exitcode == 0 - ), f"The cfg subcommand write is not working as intended.\nError : {err}" - + ), f"The cfg subcommand write is not working as intended.\nError : {err.decode()}" with open(cfgfilepath, "r") as cfgfile: assert "sound = False" in cfgfile.read() diff --git a/tests/test_cli/write_cfg_sbcmd_input.txt b/tests/test_cli/write_cfg_sbcmd_input.txt index a9fd7d0d55..ee5bbcfcae 100644 --- a/tests/test_cli/write_cfg_sbcmd_input.txt +++ b/tests/test_cli/write_cfg_sbcmd_input.txt @@ -25,5 +25,7 @@ False + + diff --git a/tests/test_logging/expected.txt b/tests/test_logging/expected.txt index ac9ee92030..9708fae5ed 100644 --- a/tests/test_logging/expected.txt +++ b/tests/test_logging/expected.txt @@ -1,4 +1,4 @@ - INFO Read configuration files: config.py: - INFO scene_file_writer.py: - File ready at - + INFO Read configuration files: config.py: + INFO scene_file_writer.py: + File ready at + diff --git a/tests/test_logging/test_logging.py b/tests/test_logging/test_logging.py index 892889a6ef..05b9dbbefa 100644 --- a/tests/test_logging/test_logging.py +++ b/tests/test_logging/test_logging.py @@ -1,7 +1,6 @@ import subprocess import os import sys -from shutil import rmtree import pytest import re @@ -14,6 +13,7 @@ def capture(command, instream=None): return out, err, proc.returncode +@pytest.mark.usefixtures("clean_tests_cache") def test_logging_to_file(python_version): """Test logging Terminal output to a log file. As some data will differ with each log (the timestamps, file paths, line nums etc) @@ -21,7 +21,7 @@ def test_logging_to_file(python_version): whitespace. """ path_basic_scene = os.path.join("tests", "tests_data", "basic_scenes.py") - path_output = os.path.join("tests_cache", "media_temp") + path_output = os.path.join("tests", "tests_cache", "media_temp") command = [ python_version, "-m", @@ -37,8 +37,8 @@ def test_logging_to_file(python_version): ] out, err, exitcode = capture(command) log_file_path = os.path.join(path_output, "logs", "SquareToCircle.log") - assert exitcode == 0, err - assert os.path.exists(log_file_path), err + assert exitcode == 0, err.decode() + assert os.path.exists(log_file_path), err.decode() if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"): enc = "Windows-1252" else: diff --git a/tests/testing_utils.py b/tests/testing_utils.py index f2d1522b95..92cfef9dbc 100644 --- a/tests/testing_utils.py +++ b/tests/testing_utils.py @@ -48,10 +48,11 @@ def __init__(self, scene_object, module_tested, caching_needed=False): ) file_writer_config["skip_animations"] = True + file_writer_config["disable_caching"] = True + file_writer_config["write_to_movie"] = False config["pixel_height"] = 480 config["pixel_width"] = 854 config["frame_rate"] = 15 - # By invoking this, the scene is rendered. self.scene = scene_object() diff --git a/tests/tests_data/manim.cfg b/tests/tests_data/manim.cfg index 6a3178abfb..9986617c4c 100644 --- a/tests/tests_data/manim.cfg +++ b/tests/tests_data/manim.cfg @@ -6,4 +6,4 @@ save_last_frame = False # save_pngs = False [logger] -log_width = 256 \ No newline at end of file +log_width = 512 \ No newline at end of file