Render a Python function's execution as an animated video: a highlight walks the source listing while the variables update alongside it.
examples/two_sum.py, rendered by visual-trace two_sum.py. Nothing in that file mentions this library -- nums is a plain
list and d a plain {}; the cells, the lookups and the membership sweeps all
fall out of the code's own data access.
uv tool install git+https://github.com/tlyleung/visual-traceThat puts a visual-trace command on your PATH; pipx install git+https://github.com/tlyleung/visual-trace
works too. To work on Visual Trace itself, clone the repo and run uv sync
instead -- see Contributing.
Manim ships its own rendering wheels, but shells out to ffmpeg, which you
need on your PATH:
ffmpeg -version # apt install ffmpeg / brew install ffmpegA script to be traced defines main(), returning the function to trace and the
arguments to trace it with. Write ordinary Python:
def two_sum(nums, target):
seen = {}
for i in range(len(nums)):
num = nums[i]
if target - num in seen:
return seen[target - num], i
seen[num] = i
def main():
return two_sum, ([2, 7, 11, 15], 9)visual-trace two_sum.py
visual-trace two_sum.py --quality high_qualityFrom a clone, that is uv run visual-trace examples/two_sum.py.
The video lands in media/videos/. The default is 854x480, which is fine as a
smoke test but too small to read the code listing — use --quality high_quality for anything you intend to watch.
list and dict are rewritten into animated containers before your code runs,
so [2, 7, 11, 15] draws as a row of cells and {} as stacked key/value cells.
Indexing lights a cell, in sweeps the keys, a write transforms the label — the
animation falls out of your code's own data access rather than being scripted.
The rewrite happens on the syntax tree and is compiled against your original file, so the code panel shows exactly what you wrote, not the rewrite, and line numbers line up.
Nothing in examples/ names List or Dict, and neither should your code.
- Other modules. Only the traced file is rewritten, so a helper imported from elsewhere keeps its plain containers and they render as text.
- Containers built by something else.
sorted(...),json.loads(...)and third-party returns are plain, and render as text. - Modules that use
listordictas a name of their own. The whole file is left alone rather than guessing at scope. - Anything over 32 elements renders as text: each cell costs a Manim mobject, and the panel only fits about eight across anyway.
For the first two you can reach for the containers by hand, which is the only way to animate something the rewrite never sees. A helper module that imports them itself hands back a container that animates in the traced function:
from visual_trace.data_structures.list import List
def build():
return List(3, 1, 2)Importing them in the traced file is not a conflict either -- the rewrite seeds the same two objects into your module, so your import just rebinds them and your plain literals are still rewritten around it.
List takes elements, not an iterable. It is varargs, so List(xs) is a
one-element list holding xs -- splat it, List(*xs). Dict has no such
catch; it mirrors dict's own signature. The rewrite emits the splat for you,
which is why this only bites on the hand-written path.
Pass --no-rewrite to have your list and dict taken literally instead.
| Data Structure | Implemented | Link |
|---|---|---|
| List | ✅ | List |
| Dict | ✅ | Dict |
| Stack | ❌ | |
| Queue | ❌ | |
| Tree | ❌ | |
| Graph | ❌ |
Reads, writes, insertions and removals all animate. Anything left inherited from
the builtin would desync the drawing from the data, so cell_values_match fails
the render rather than letting it show stale values.
| Example | Shows |
|---|---|
two_sum |
dict lookups, membership sweeps |
max_sub_array |
iterating a list |
bubble_sort |
in-place swaps |
binary_search |
indexed reads |
list_operations |
appends |
dict_operations |
values(), clear() |
- The variables panel fits about eight cells across. A longer container is drawn past the right edge of the frame and clipped, and past 32 elements it is shown as text instead.
- A traced function runs twice — once to collect variable names, once to animate — so it should not have side effects outside its arguments.
- Only integer indices animate; slice assignment redraws rather than animating the change.
uv sync # set up the clone
uv run pytest tests/ -q # fast geometry checks
uv run scripts/verify.py examples/two_sum.py # full render + probeverify.py needs ImageMagick as well as ffmpeg -- it shells out to convert,
montage and compare to build the contact sheet and pixel diffs
(apt install imagemagick / brew install imagemagick). The tool itself needs
only ffmpeg.
CI runs the tests on 3.12 and 3.13, builds the package, and renders an example
under the probe -- verify.py exits non-zero if any invariant or the
landmark-drift check fails, so a visual regression breaks the build.
verify.py renders an example and reports per-step geometry assertions, a
landmark-drift check and a captioned contact sheet in media/verify/, so a
change can be reviewed without watching the video.
CLAUDE.md covers how tracing, the animation queue and that loop fit
together.
This project is licensed under the GNU General Public License v3.0. See the LICENSE file for details.
