Manim's caching: deterministic frames and per-play() content hashing
Why re-running an unchanged scene is nearly free. Manim Community caches at the granularity of a single self.play(...) call: it hashes the camera, the animations, and the scene's mobjects into one string, and if a partial movie file with that name already exists on disk, it skips rendering entirely. The whole scheme rests on deterministic frame sampling — without it, the same input could produce different frames and the cache would be unsound. This is the determinism/caching companion to the Manim Community deep-dive; shared terms are in ../concepts.md.
How it works
Determinism: a fixed frame grid
An animation's frames are sampled on a fixed grid, not by wall-clock. In Scene.get_time_progression (scene.py:1051):
# scene.py:1087 — frames land on a fixed 1/fps grid, independent of render speed
step = 1 / config["frame_rate"]
times = np.arange(0, run_time, step)and update_to_time (scene.py:1687) maps each sampled t to a normalized progress alpha = t / animation.run_time before interpolating. The same (animation, run_time, frame_rate) therefore always visits the same t values and produces the same alpha sequence — deterministic frame sampling. Interpolation itself is pure array math (self.points = path_func(m1.points, m2.points, alpha), mobject.py:3149), so a given alpha always yields the same geometry, and Cairo rasterizes it identically. The frame-capture.d probe demonstrates the invariant the cache depends on: identical input bytes → identical checksum across runs.
The content hash of a play() call
Before rendering, CairoRenderer.play (cairo_renderer.py:64) computes a hash and short-circuits on a cache hit:
# cairo_renderer.py:87 — hash this play() call; skip rendering if already cached
hash_current_animation = get_hash_from_play_call(
scene, self.camera, scene.animations, scene.mobjects)
if self.file_writer.is_already_cached(hash_current_animation):
self.skip_animations = True
self.time += scene.durationget_hash_from_play_call (hashing.py:333) hashes three inputs independently and concatenates them. Its docstring defines the key exactly:
"A string concatenation of the respective hashes of
camera_object,animations_listandcurrent_mobjects_list, separated by_." —hashing.py:358
# hashing.py:363 — serialize each input to JSON, crc32 it, join with "_"
camera_json = get_json(camera_object)
animations_list_json = [get_json(x) for x in sorted(animations_list, key=str)]
current_mobjects_list_json = [get_json(x) for x in current_mobjects_list]
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])
hash_complete = f"{hash_camera}_{hash_animations}_{hash_current_mobjects}"Each component is a zlib.crc32 over a custom JSON serialization (get_json → _CustomEncoder, hashing.py:317). Two subtleties make the JSON stable:
- Run-dependent fields are stripped.
KEYS_TO_FILTER_OUT = {"original_id", "background", "pixel_array", "pixel_array_to_cairo_context"}(hashing.py:29), flagged in-source because "Sometimes there are elements that are not suitable for hashing (too long or run-dependent). This is used to filter them out." (hashing.py:27). A per-run object id or the live pixel buffer would otherwise poison the hash. - Circular references are broken. The scene graph is a tree with shared references, so
_Memoizer(hashing.py:37) tracks every object it has serialized — keyed byhash, falling back toid(hashing.py:135) — and replaces a second sighting with the placeholder"AP"(ALREADY_PROCESSED_PLACEHOLDER,hashing.py:53). After each hash the registry is reset (reset_already_processed,hashing.py:374) so the nextplay()starts clean.
The hash is the filename
There is no separate cache index. add_partial_movie_file (scene_file_writer.py:249) names the clip after the hash directly:
# scene_file_writer.py:270 — the content hash becomes the partial-movie filename
new_partial_movie_file = str(
self.partial_movie_directory / f"{hash_animation}{config['movie_file_extension']}")so is_already_cached (scene_file_writer.py:606) is a single filesystem check — path.exists() on partial_movie_directory / f"{hash_invocation}{movie_file_extension}". Each play() writes one partial movie file (open_partial_movie_stream, scene_file_writer.py:540); finish() concatenates all of them with FFmpeg's concat demuxer into the final video (scene_file_writer.py:652). On a cache hit, rendering is skipped and the existing partial file is reused in that concat list — the concrete payoff of the whole scheme.
Cache eviction and disabling
clean_cache (scene_file_writer.py:857) keeps the partial-movie directory bounded: when the file count exceeds config["max_files_cached"] (default 100, default.cfg), it deletes the oldest files ranked by access time (st_atime, scene_file_writer.py:870) — an LRU policy. Caching can be turned off with disable_caching (cairo_renderer.py:82, which names each animation f"uncached_{self.num_plays:05}" instead), and the whole directory flushed with flush_cache_directory (scene_file_writer.py:879).
Determinism, caching & performance (analysis)
| Property | Mechanism | Source |
|---|---|---|
| Frame determinism | np.arange(0, run_time, 1/frame_rate) fixed grid | scene.py:1087 |
| Progress determinism | alpha = t / run_time; pure path_func lerp | scene.py:1693 · mobject.py:3149 |
| Cache granularity | one hash + one partial movie file per play() | cairo_renderer.py:64 |
| Cache key | crc32(camera)_crc32(anims)_crc32(mobjects) of custom JSON | hashing.py:333 |
| Hash stability | KEYS_TO_FILTER_OUT + _Memoizer circular-ref placeholder | hashing.py:27 |
| Cache lookup | path.exists() — the hash is the filename | scene_file_writer.py:606 |
| Eviction | LRU by st_atime, capped at max_files_cached (100) | scene_file_writer.py:857 |
WARNING
The soundness of the cache is only as good as the hash's coverage. Because KEYS_TO_FILTER_OUT and the custom encoder omit fields, a change confined to an un-serialized attribute would not invalidate the cache — the engine would silently reuse a stale frame. This is the standard content-hash trade-off: cheap lookups in exchange for trusting that the hashed subset fully determines the output. The default guard is disable_caching, and the per-play() granularity keeps any staleness localized to one clip.
The performance win compounds with the static/moving split: begin_animations (scene.py:1334) paints non-moving mobjects once into a static image so only moving_mobjects are re-rasterized per frame. Together, deterministic sampling (correctness), content hashing (skip unchanged clips), and the static image (skip unchanged pixels within a clip) are the three layers that make iterative editing of a long scene practical on a single-threaded CPU rasterizer.
Sources
manim/utils/hashing.py—get_hash_from_play_call,get_json/_CustomEncoder,KEYS_TO_FILTER_OUT,_Memoizercircular-ref handling.manim/renderer/cairo_renderer.py— theplaycache check,disable_caching.manim/scene/scene.py— deterministic time progression,update_to_time, static/moving split.manim/scene/scene_file_writer.py— partial movie files,is_already_cached, concat, LRUclean_cache/flush_cache_directory.manim/_config/default.cfg—max_files_cached,disable_caching,frame_rate.
Related: the Manim Community deep-dive · scene-graph.md · text-pipeline.md · ../concepts.md · manimgl. Probe: frame-capture.d.