codimate
Codimate — turn a running algorithm into an explainer video.
You write four things, and never a keyframe:
algorithm your normal code, with emit() where something happens
view what one moment looks like
motion how things travel between moments
timing how long each moment lasts
Codimate pairs shapes between moments by name and turns the differences into movement. Everything per-frame — diffing, interpolation, drawing, encoding — happens in Rust (ADR 0008).
What your algorithm did
trace— mark a function so Codimate can watch it runemit— say that something worth showing just happeneditems— a list whose entries keep their identity when they moveItem,Event,Trace,Frame— what your view is handed
What a moment looks like
Scene— one picture:rect,circle,arc,polygon,curve,arrow,text,line,formula,svg,imagengon,star— corners for a polygon, so you do not compute themGroup— several shapes that move togetherHandle— what a shape call returns:.fill(),.round(),.turn(),.grow(),.on(),.write(), chained
Where things sit
canvas,width,height— the framerow,column,Slot— divide it up, without coordinatesmeasure,measure_math— how big text or a formula will actually be
How it moves, and for how long
Rule— the path a shape travelsTiming— how long each event lastsease— the curve the Engine uses, if you need to draw it
Running it
explain— gather algorithm, view, motion and timingExplanation.render— write the video
Record that something just happened.
Call it after changing your data — Codimate snapshots the result for you.
Turn a normal, state-mutating function into a Trace.
snapshot receives the same arguments as your function and returns a
copy of the data worth showing. Defaults to a deep copy of the first
argument — Items keep their identity through it.
Give each value an identity of its own.
values = cm.items([3, 1, 4, 2])
Use it when the things on screen should be able to move — two equal values are still two different things. For a grid whose cells stay put, you do not need this: key those on their position instead.
A value with an identity of its own.
Two 3s in a list are two different bars, and only an identity can say so. An Item compares by value (so your algorithm sorts normally) and is equal by id (so it is still itself after Codimate snapshots the state).
What your view function receives: the data, and what just happened.
event is None for the opening moment, before anything has happened.
The picture at one moment. No animation, no timing, no memory of the frame before.
Draw on it with the shapes it inherits from Group — rect, circle,
text, line, formula — and group to put several of them together.
Every shape carries a name — the identity of the thing you are talking about. Motion is implied by identity: the Engine pairs shapes by name between one moment and the next, and whatever changed becomes movement. A name that follows a value travels; a name that follows a position stays put and changes shape. Both are useful, and choosing wrong produces a confusing video, not an error.
Look at these things. The camera works out where to stand.
scene.focus("cat") # frame this shape
scene.focus(("cell", 2, 1), pad=60) # any name, with room around it
scene.focus("The", "cat", "sat") # several — all of them in shot
scene.focus() # the whole canvas again
You never write a camera coordinate. The Engine knows where everything is and how big it is, so it derives the framing — and because each moment is framed on its own, a camera that moves between two moments is just two sets of coordinates that differ, which the usual tween animates. Camera movement needs nothing new.
Naming rather than positioning also means the shot survives a layout change: move the shape and the camera follows, because the name is the part that is stable.
least is the smallest thing the camera will fill the frame with, so
focusing on a full stop does not magnify it into abstraction.
Anything on an overlay() stays where it is put.
A place for things the camera does not move.
hud = scene.overlay()
hud.text("title", "Scaled dot-product attention", x=640, y=52)
The diagram pans and zooms underneath; whatever is drawn here stays put. Titles and narration belong here — the moment the picture zooms, a caption that zooms with it is wrong, and usually off the edge of the frame entirely.
Content is in the world unless it says otherwise, because in an explanation most of the frame is diagram and only a little is narration.
Somewhere to draw, with its own origin and its own name.
Everything drawn on a Group moves as one thing, because the Engine sees each child's name as part of the group's name.
Inside a Group, 0 is the Group's anchor point. bottom=0 stands
a shape on it; top=20 puts one 20 below it.
A rectangle. Say where with a point, a Slot, or cm.at(...).
scene.rect("box", h=40, at=cm.at(bottom=0)).fill("blue")
Round the corners with .round(12).
Real mathematics, written as LaTeX.
scene.formula("eq", r"\frac{QK^{T}}{\sqrt{d_k}}", size=34)
Use a raw string, or every backslash needs doubling. size means what
it means for text(). Draw it on with .write(reveal=, pen=).
The result is glyph outlines, not a font — so it moves, fades and
recolours like any other shape, and is typeset once when the video is
built. Needs the typst binary on PATH, the way rendering needs
ffmpeg.
A slice of a circle — an angle mark, a pie, a dial, an orbit.
scene.arc("angle", r=90, sweep=(0, 50)).fill("none", edge="cyan",
edge_w=3)
scene.arc("slice", r=120, sweep=(0, 120)).fill("orange").round(1)
sweep is (start, end) in degrees, clockwise from twelve o'clock —
the same zero cm.ngon uses. r is the radius, or (rx, ry) for an
ellipse.
Open by default, so it draws as a curved line. .round(1) closes it
back to the centre and makes a pie slice you can fill.
Two arcs always tween, however far apart their angles are, so an angle
mark grows and a pie fills smoothly. That is what this cannot be done
with curve: a curve through points on a circle changes its point
count when the sweep changes, and then it snaps instead of sweeping.
A picture — a photo, a screenshot, a figure — drawn into the frame.
scene.image("paper", "figures/attention.png", size=(520, 300))
scene.image("shot", "screen.png", size=400).on(opacity=0.4)
PNG and JPEG, read by content rather than by extension. size is a box
it fits inside, one number or (w, h), aspect always kept; leave it out
and the picture is drawn at its own pixel size.
It moves, scales, turns and fades like anything else, and focus()
frames it. For a logo or a diagram prefer svg, which arrives as
geometry you can recolour and draw on — an image is pixels, so it can
only be placed.
Vector art from a file, drawn as real geometry.
scene.svg("logo", "brand.svg", size=90, at=cm.at(x=1180, top=24))
scene.svg("chart", "flow.svg", size=(900, 420))
Because it arrives as geometry rather than pixels it behaves like
anything else you draw: it tweens, .turn() and .grow() transform
it, focus() frames it, and .write(pen=2) draws it on stroke by
stroke.
size is a box it fits inside — one number for a square, or (w, h).
The aspect ratio is always kept, so a wide diagram and a tall one both
land inside the box you named.
The artwork keeps its own colours. .fill(colour) overrides all of
them at once, which flattens it to a silhouette on purpose.
Labels come across as real text, shaped by the same code that draws
every scene.text. Layout that cannot be read is refused rather than
guessed at — a tspan, a textPath, or a turned label, since the
renderer cannot turn glyphs.
A shape with corners: a triangle, a wedge, an arrow head, a wing.
scene.polygon("tri", cm.ngon(3, r=50, at=(640, 360))).fill("green")
points is a sequence of (x, y). Closed and filled by default;
closed=False leaves an open outline, which shows only with an edge.
Two polygons tween only if they have the same number of corners — interpolating a triangle into a pentagon has no answer worth inventing, so the later shape stands for the whole beat instead.
A smooth line through every one of points.
scene.curve("plot", samples, w=3).fill("cyan")
The points are on the curve, not control points: you hand it samples —
a function you plotted, a path something travelled — and it draws a
smooth line through them. w is the stroke width.
Open by default, and an open curve is drawn rather than filled, the
way a line is. closed=True joins the ends and makes it a fillable
shape, like a polygon with rounded-off corners.
Two curves tween only if they have the same number of points, the same rule polygons follow; otherwise the later one stands for the beat.
An arrow, as a single filled shape.
One shape rather than a line plus a separate head, so it carries one
name and travels as one thing. w is the shaft, head the point.
A line between two points. w is its thickness.
A line has no centre to anchor, so it takes its two ends directly. Each
may be a Slot — a line joins the middles of two places — or an (x, y).
A place to draw a thing made of several shapes.
bar = scene.group(item.id, slot)
bar.rect("bar", h=item.value * 70, bottom=0)
bar.text("label", item.value, top=20)
Give it a Slot and it sits where the Slot says, or place it with at
like any shape. Inside it, 0 is that point, and everything drawn on
it moves as one.
What a shape call gives back: a way to say more about that shape.
Chained rather than passed, so no function carries eighteen arguments:
scene.rect("bar", h=40, at=cm.at(bottom=0)).fill("blue").turn(30)
Each call returns the handle again, and each one is small enough to read.
Colour it. edge outlines it; color="none" leaves it unfilled.
Saying nothing about the outline leaves the outline alone, so
recolouring a shape does not silently erase the edge it was given —
by an earlier fill, or by curve, which carries its stroke width
there.
Rotate it. pivot is center, top, bottom, left or right.
Text moves but does not turn — rotating glyphs is renderer work that has not been done.
How much of a formula or an imported SVG shows, and whether a pen draws it on.
The pen rides in a different field for the two kinds — w for a
formula, size for an SVG, whose w is already the box it fits
inside. The payload reuses fields per kind by design (ADR 0008); this
is the one place that reuse is visible from Python.
Set the size of the video. Defaults to 1280x720.
How wide and tall text will be at size: (w, h).
For drawing a box around a label without guessing::
w, h = cm.measure(label, size=30)
scene.rect("box", x=x, y=y, w=w + 24, h=h + 12, radius=6)
scene.text("label", label, x=x, y=y, size=30)
Measured by the engine with the real fonts, including fallback, so it is
right for Khmer and anything else that is not plain ASCII — which is why
estimating len(text) * size * k is not good enough.
The height is the line height, the same for "cat" and "Qgy", so a row of boxes lines up instead of jittering with whatever letters it holds.
How wide and tall a LaTeX formula will be at size: (w, h).
The counterpart of measure(), so a formula can be laid out beside
words — a caption that mixes prose and mathematics needs both.
The canvas width. cm.width() / 2 is the horizontal centre.
The canvas height.
Where a shape goes, as one value.
Built by at(). Exists so a shape takes one placement argument
instead of six — the six were one idea wearing a disguise.
One Slot per item, evenly spaced and centred on the canvas.
for slot, item in cm.row(values, gap=40, size=190):
...
size is one number for the Slot's width (its height follows), or
(w, h) for both.
A row lays things out on a shared baseline, so its Slots anchor at
bottom-centre — hand one straight to scene.group().
Pass within=slot to divide up part of the canvas instead of all of it,
so a chart can live in its own corner without any arithmetic of yours.
Yields (slot, item) pairs, or bare Slots if you passed a count.
One Slot per item, stacked vertically and centred on the canvas.
for slot in cm.column(4, gap=40, at=cm.at(x=640)):
...
A column stacks things around a centre line, so its Slots anchor at their
centre. x places the column horizontally; it defaults to mid-canvas.
Pass within=slot to stack inside part of the canvas instead of all
of it.
Yields (slot, item) pairs, or bare Slots if you passed a count.
The corners of a regular polygon, for Scene.polygon().
scene.polygon("tri", cm.ngon(3, r=60, at=(640, 360)))
A triangle is three sides, a hexagon six. turn rotates it in degrees —
the first corner otherwise points straight up.
Returns points rather than drawing, so it composes: you can shift them,
hand them to polygon, or measure them yourself.
The corners of a star, for Scene.polygon().
scene.polygon("s", cm.star(5, r=80, at=(640, 360)), color="yellow")
inner is the radius of the valleys; it defaults to a proportion that
looks like a star rather than a gear.
How things matching pattern travel. First matching rule wins.
cm.Rule("*", position="lift_carry_drop", clearance=90)
pattern matches a shape's full name with * and ?. A shape
inside a group is named group/child, so "3/*" targets one group and
"*" targets everything.
Paths:
straight— a straight line, easing in and out. The default, and what you want when each event is a distinct step.linear— a straight line at constant speed. Use it when a thing is mid-journey at every event, like something turning: easing would make it accelerate and stop inside each segment.fall— a parabola: sideways at a constant rate, downwards accelerating. What a dropped thing does, and what each hop of a falling ball needs, since an eased path would settle gently instead of arriving.lift_carry_drop— arcs up and over, then falls. Takesclearance.
How long each event lasts, in seconds.
The easing curve the Engine applies between two moments.
cm.ease(0.5) -> 0.5
This calls into the Engine, so it is the same curve your animation is actually using — not a copy of it.
Gather an algorithm, a view and a timing into something renderable.
trace is what a @cm.trace()-marked function returns: the moments
your algorithm passed through. view is called once per moment and
returns the picture of it. motion and timing are optional —
without them every shape travels in a straight line and every event lasts
the same.
cm.explain(trace=flip(tally), view=view).render("results/coins.mp4")
Nothing is computed here; the work happens in Explanation.render().
A trace, a view and a timing, ready to render.
Built by explain() rather than directly. Holds one Scene per Trace
Event and the gap between each pair; render() hands all of it to the
Engine once, and everything per-frame happens in there.
Draw every frame and write the video.
Coordinates always mean what cm.canvas() says — scale only changes
how many pixels each one becomes, so nothing in your view has to move:
.render("out.mp4", fps=60, scale=1.5) # 1080p60 from the default
Frames are rasterized at the larger size rather than upscaled afterwards, so 1080p is genuinely drawn at 1080p.
The folder is created if it does not exist, so render("results/x.mp4")
works on a fresh clone.
Save a single moment as a PNG, without rendering the video.
cm.explain(...).frame_at(12.5, "check.png")
The same scenes, timing and arithmetic as render(), resolved at
one instant. Checking a frame by rendering the whole video and seeking
into it costs a minute to look at one second.
scale matches render's, so the debug frame is rasterized the
way the video is — worth passing when you are checking text, which is
the thing that has historically differed between the two.
Every beat as (start, duration, event name), in seconds.
for start, length, name in cm.explain(...).timeline():
print(f"{start:6.2f} {length:4.2f} {name}")
What is on screen at 0:42, and how long each beat actually lasts —
the two questions you have when a video feels wrong. Pair it with
frame_at() to look at the moment you find.