Repository: ManimCommunity/manim
Pull request: Added Scene caching
Closes #8 

First of all, big thank you to @Aathish04 who helped me and allowed me to get inspiration from his previous attempt to build a Scene Cacher.

## Additions : 
`Scene-caching` : if a play or wait invocation has already been rendered, manim now take the already rendreded video instead of rendering it again! If the cache is full (too much partial movie files), manim will remove some files. See below 
`--disable_caching `flag : self explanatory. Will render the invocation anyway, and will save the partial movie file. This won't use any hash and name the partial movie file with a incremental index.
`--flush_cache` Will delete all the files cached.

## How it works (up to date)
It computes three hashes and then it names the partial_movies files generated with the concatenation of these hashes (separated by "_"). 
- The first hash corresponds to the arguments passed to `scene.play` (or `scene.wait`). It converts the list of the arguments in a JSON type array, and then create a hash (with crc32). Everything is included in this array, as the arguments are recursively "serialized" i.e the objects are converted to their `__dict__`, functions to their definition codes, etc. See `get_hash_from_play_call` and `get_hash_from_wait_call` in utils/hashing.py l.52.
- The second hash is obtained with all the mobjets present on the scene, including **those not involved in the current `play`/`wait `invokation**. Same method as above, the mobjects are converted into JSON array.
- The last one is obtained with the camera_config, with the same process describing above.
Note: **All of the process** is -of course- recursive. So it's able to handle for example nested dicts.

I also have to serialize functions. To do this, I took the source code _(taking dissassamelby of the function won't work, as it uses sometimes references  (`object <> at [memory address]`) in the Bytecode that appear in the str representation of the function's instructions, and we want to avoid that as memory addresses are random so it will affect the hash every time_. I also took the non-locals vars that are used by the function in the hash: So if something external to the function is modified but not its source code directly, the hash will be affected. (Note: As the whole process, this is recursive.)

To use these cached files, we just check if the file with the hash exists; if yes, we use it when combining the partial_movie files : if not, we generate the partial_movie file.

If the cache is full (if there are more than x files in the directory that contains the partial movie files, when x is a parameter that can be put by the user), then manim removes the partial movie that was **used** (i;e opened by manim) the longest ago (and not _created_ the longest ago). Note that in a lot of system, the last_accessed_time of a file is not refreshed automatically, so I had to do this manually (see `utils.file_ops.modify_atime` (l.61)). Maybe this is too much _hacky_ ?

If disable_caching is `True`: we don't even compute a hash. We just use an incremental index for the `partial_movie_files` as it used to be.

## Testing 
Given that we don't at the moment have a way to test video this PR won't come with tests implemented.
Nevertheless, this have been manually tested many times on many different configurations.

## TODO : 
 - Very probably some docs things. Pinging Captain Docs, (his real identity is @PgBiel) 

Problem statement:
Adding a Scene Cacher
For tedious and long animations, people often have to render it multiple times just to see a small change they made, wasting lots of time. I'm not exactly sure how we could accomplish this, but we could look into having a way to cache scenes that have already been animated. Since all partial scenes are already stored, it would be easy to stitch together scenes that have already been animated. Also, you could verify that the scene has already been animated by adding it to some sort of array containing the hashes (MD5) of the scene, and hashing each scene before animating it. This way, if a scene has already been animated, it will skip over re-animating it and instead include the partial movie file that it has already been animated in. This could save a lot of time for people who come to use manim more often, so it should, at least somewhat, be a priority in this user-experience-based version of manim.

Hints from issue discussion:
This is a very good idea. 

That reminds the optimization with the non-moving objects `self.update_frame(excluded_mobjects=moving_mobjects)` (l. 921 of scene.py) Maybe we could do something like that. 
Very very good. I'd love to see a PR on this. @safinsingh would you be down to do that?
I can try to, but I'm not exactly sure how to get started
Maybe [ffprobe](https://ffmpeg.org/ffprobe.html) can be used? Though I don't know what impact it'll have on speed.

I was thinking of a system where, similar to what tex_file_writing.py does, the partial_video_files are named with a completely unique name, maybe like safinsingh suggested, with the md5 hash of something completely unique to each video file.

Since each play() like call makes its own video, maybe the md5 hash of the pixel array at the middle of each self.play would be unique enough?

We can then check if the hash matches with the name of the video file, and if it doesn't, then ffmpeg can rewrite the video file.


But now there's the issue of how we can get to the middle of the self.play without writing whatever came before it to the video file...
https://dsp.stackexchange.com/a/15133
https://stackoverflow.com/questions/32317857/digital-signature-hash-of-mp4-video

I was thinking of doing something like that too but apparently hashing video files and their subsegments isn't all that easy.
We don't need to hash the video after it's been created.

We hash a single/multiple frame(s) from the video, and name the video with that, and compare the names the next time it's rendered.

Since we can get at the pixel arrays of the frames pretty easily, we could get the hashes of those.

Now that I think about it, we could get the whole contents of the self.play call as a list of pixel arrays of the frames, and name the temporary mp4 with that...
@Aathish04 But what is the interest if we render the scene _before_ comparing ?
I mean, it's useless since (if I did understand well) what we want to avoid is rendering. 
If we build a mechanism like that it should work before the rendering and prevent manim to even have the already-rendered scenes in memory. 
> @Aathish04 But what is the interest if we render the scene _before_ comparing ?
> I mean, it's useless since (if I did understand well) what we want to avoid is rendering.
> If we build a mechanism like that it should work before the rendering and prevent manim to even have the already-rendered scenes in memory.

You raise a good point. 

My partially working approach to this was to leverage manim's inbuilt ability to skip _writing_ frames to FFMPEG, so the scenes are rendered, but not written to video files (kind of like what the -n flag does, but now, letting FFMPEG write the whole scene using cached and non cached video clips.  ) This itself speeds up the process quite a bit.

The problems with the above approach were that the modifications made to the scene needed to be _extreme_ in order for my modifications to pick up on the changes and actually write the new clip to a video file. I had to completely remove objects in order for Manim to pick up the changes... which isn't ideal.

I'm not sure how we can avoid rendering _completely_, considering that we need _something_ to compare the old partial movie file with...
Would it be possible to somehow parse and hash the `play()` statements of a scene and write that to a file? That way, before even rendering an animation, it will first see if that hash has already been rendered therefore completely skipping it. Additionally, if the file containing hashes seems to be a problem, we could name the file with the md5 hash of the play statement itself concatenated with the position of mobjects (to avoid replaying clips that have the same animation but different mobject positions)

Commit message: Apply suggestions from code review

Thank you Captain Docs !

Co-authored-by: Pg Biel <9021226+PgBiel@users.noreply.github.com>

Patch to review:
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..69be6878e4
--- /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):
+            r = inspect.getclosurevars(obj)
+            x = {**copy.copy(r.globals), **copy.copy(r.nonlocals)}
+            for i in list(x):
+                # NOTE : All module types objects are removed, because otherwise it throws ValueError: Circular reference detected if not. TODO
+                if isinstance(x[i], ModuleType):
+                    del x[i]
+            return {"code": inspect.getsource(obj), "nonlocals": x}
+        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 != 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


Code review comments:
diff_hunk: @@ -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):
+            r = inspect.getclosurevars(obj)
+            x = {**copy.copy(r.globals), **copy.copy(r.nonlocals)}
+            for i in list(x):
+                # NOTE : All module types objects are removed, because otherwise it throws ValueError: Circular reference detected if not. TODO
+                if isinstance(x[i], ModuleType):
+                    del x[i]
+            return {"code": inspect.getsource(obj), "nonlocals": x}
+        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 != None:
path: manim/utils/hashing.py
text: @user1:
```suggestion
    if stop_condition_function is not None:
```

diff_hunk: @@ -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):
+            r = inspect.getclosurevars(obj)
+            x = {**copy.copy(r.globals), **copy.copy(r.nonlocals)}
+            for i in list(x):
+                # NOTE : All module types objects are removed, because otherwise it throws ValueError: Circular reference detected if not. TODO
+                if isinstance(x[i], ModuleType):
+                    del x[i]
path: manim/utils/hashing.py
text: @user1:
we should have better names here (mine are merely a suggestion)
```suggestion
            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]
```