Skip to content

Repository files navigation

Visual Trace

Render a Python function's execution as an animated video: a highlight walks the source listing while the variables update alongside it.

CI

A traced run of two_sum: a highlight bar walks down the source listing on the left while a variables table on the right updates, with nums drawn as a row of cells and d as stacked key/value cells

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.

Installation

uv tool install git+https://github.com/tlyleung/visual-trace

That 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 ffmpeg

Usage

A 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_quality

From 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.

How your containers get animated

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.

What the rewrite does not reach

  • 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 list or dict as 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 Structures

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.

Examples

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

Known limits

  • 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.

Contributing

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 + probe

verify.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.

License

This project is licensed under the GNU General Public License v3.0. See the LICENSE file for details.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages