Skip to content

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

python
# 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:

python
# 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.duration

get_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_list and current_mobjects_list, separated by _."hashing.py:358

python
# 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 by hash, falling back to id (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 next play() 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:

python
# 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)

PropertyMechanismSource
Frame determinismnp.arange(0, run_time, 1/frame_rate) fixed gridscene.py:1087
Progress determinismalpha = t / run_time; pure path_func lerpscene.py:1693 · mobject.py:3149
Cache granularityone hash + one partial movie file per play()cairo_renderer.py:64
Cache keycrc32(camera)_crc32(anims)_crc32(mobjects) of custom JSONhashing.py:333
Hash stabilityKEYS_TO_FILTER_OUT + _Memoizer circular-ref placeholderhashing.py:27
Cache lookuppath.exists() — the hash is the filenamescene_file_writer.py:606
EvictionLRU 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

Related: the Manim Community deep-dive · scene-graph.md · text-pipeline.md · ../concepts.md · manimgl. Probe: frame-capture.d.