Codimate Home Tutorial Concepts Drawing Reference API

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 run
  • emit — say that something worth showing just happened
  • items — a list whose entries keep their identity when they move
  • Item, 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, image
  • ngon, star — corners for a polygon, so you do not compute them
  • Group — several shapes that move together
  • Handle — what a shape call returns: .fill(), .round(), .turn(), .grow(), .on(), .write(), chained

Where things sit

How it moves, and for how long

  • Rule — the path a shape travels
  • Timing — how long each event lasts
  • ease — the curve the Engine uses, if you need to draw it

Running it

def emit(name: str, **data: Any) -> None:

Record that something just happened.

Call it after changing your data — Codimate snapshots the result for you.

def trace(*, snapshot: Callable[..., Any] = None):

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.

def items(values) -> list[Item]:

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.

class Item:

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

@dataclass(frozen=True)
class Frame:

What your view function receives: the data, and what just happened.

event is None for the opening moment, before anything has happened.

def is_(self, name: str) -> bool:

Did this moment come from an event called name?

def items(self, key: str = 'items') -> list:

The things this event named, e.g. frame.items().

Use it when you emitted a list: cm.emit("compare", items=[a, b]). Empty for the opening moment.

class Scene(codimate.Group):

The picture at one moment. No animation, no timing, no memory of the frame before.

Draw on it with the shapes it inherits from Grouprect, 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.

def focus(self, *names: Hashable, pad: float = 40.0, least: float = 240.0) -> None:

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.

def overlay(self) -> Group:

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.

class Group:

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.

def rect( self, key: Hashable, *, h: float, w: float = None, at=None) -> Handle:

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

def circle(self, key: Hashable, *, r: float, at=None) -> Handle:

A filled circle. color="none" with .fill(edge=...) draws a ring.

def text( self, key: Hashable, content: Any, *, size: float = 16.0, at=None) -> Handle:

Text, centred horizontally. You never deal with baselines.

def formula( self, key: Hashable, latex: str, *, size: float = 16.0, at=None) -> Handle:

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.

def arc(self, key: Hashable, *, r, sweep, at=None) -> Handle:

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.

def image( self, key: Hashable, file, *, size=None, at=None) -> Handle:

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.

def svg( self, key: Hashable, file, *, size=120.0, at=None) -> Handle:

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.

def polygon( self, key: Hashable, points, *, closed: bool = True) -> Handle:

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.

def curve( self, key: Hashable, points, *, w: float = 2.0, closed: bool = False) -> Handle:

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.

def arrow( self, key: Hashable, *, start, end, w: float = 4.0, head: float = 16.0) -> Handle:

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.

def line( self, key: Hashable, *, start, end, w: float = 2.0) -> Handle:

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

def group( self, key: Hashable, slot: Slot | None = None, *, at=None, anchor: str = None, w: float = None) -> Group:

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.

class Handle:

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.

def fill( self, color: str = 'white', edge: str = None, edge_w: float = None) -> Handle:

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.

def turn(self, degrees: float, pivot: str = 'center') -> Handle:

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.

def grow(self, scale) -> Handle:

Scale it: a number for both axes, or (sx, sy) to stretch.

def on(self, layer: int = None, opacity: float = None) -> Handle:

Which layer it draws on, and how solid it is.

def round(self, radius: float) -> Handle:

Round a rectangle's corners, clamped to half its short side.

def write(self, reveal: float = None, pen: float = 0.0) -> Handle:

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.

def canvas(w: float, h: float) -> None:

Set the size of the video. Defaults to 1280x720.

def measure(text: str, size: float = 16.0) -> tuple[float, float]:

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.

def measure_math(latex: str, size: float = 16.0) -> tuple[float, float]:

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.

def width() -> float:

The canvas width. cm.width() / 2 is the horizontal centre.

def height() -> float:

The canvas height.

@dataclass(frozen=True)
class Slot:

A place to put something. Not a shape — nothing draws a Slot.

row and column hand you these; you rarely build one.

  • x, y — its centre
  • w, h — its size
  • left, right, top, bottom — its edges
  • anchor — which edge things placed here line up on
def point(self, anchor: str | None = None) -> tuple[float, float]:

The single point this Slot anchors things at.

@dataclass(frozen=True)
class Place:

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.

def at(x=None, y=None, top=None, bottom=None) -> Place:

A place, for a shape's at=.

scene.rect("bar", h=40, at=cm.at(bottom=0))
scene.text("l", "hi", at=cm.at(x=col, top=y + 22))

Give one value per axis: x or nothing for horizontal, and y, top or bottom for vertical. A plain (x, y) or a Slot works wherever a Place does, so you only need this for edges.

def row( items, *, gap: float = 40.0, size=None, at: Place | None = None, within: Slot | None = None):

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.

def column( items, *, gap: float = 40.0, size=None, at: Place | None = None, within: Slot | None = None):

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.

def ngon(sides: int, r: float, at=(0.0, 0.0), turn: float = 0.0) -> list:

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.

def star( points: int, r: float, inner: float = None, at=(0.0, 0.0), turn: float = 0.0) -> list:

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.

class Rule:

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. Takes clearance.
class Timing:

How long each event lasts, in seconds.

def for_event(self, event: codimate.trace.Event) -> float:

How long event lasts: its own entry, or default.

A name with no entry takes default silently — which is what a default is for, but it means a misspelled event name costs you the default duration rather than an error.

def ease(t: float) -> float:

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.

def explain( *, trace: codimate.trace.Trace, view: Callable[[Frame], Scene], motion: list[Rule] | None = None, timing: Timing | None = None) -> Explanation:

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

class Explanation:

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.

def render(self, output: str, *, fps: float = 30, scale: float = 1.0) -> str:

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.

def frame_at( self, seconds: float, output: str = 'frame.png', scale: float = 1.0) -> str:

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.

def timeline(self) -> list[tuple[float, float, str]]:

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.