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 — run your algorithm and record what it did; it hands the algorithm an emit, to 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, or a poseable character
  • 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
  • Character — a connected SVG figure with per-Scene joints and expressions
  • TextPreset, TextPresetStyle — reusable word and letter entrances

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

Explaining physics

  • science — labels, glows, brackets, force arrows; a camera, spheres, orbits
def trace(fn, state, /, *, snapshot=None) -> codimate.trace.Trace:

Run fn over state and record what happened.

def bubble_sort(values, emit):
    ...
    emit("compare", items=[a, b])

cm.explain(trace=cmcodimate.trace(bubble_sort, cm.items([3, 1, 4, 2])), ...)

Your function is handed the state and an emit. Call emit after changing the state — Codimate snapshots the result for you.

emit is an argument rather than something ambient, so the answer to "where did that come from?" is the line above it, and so the function stays ordinary Python: bubble_sort(values, print) runs it and prints the events.

Write the state as a dataclass, so the algorithm and the view read it as state.x and a misspelled field is an error rather than a new key::

@dataclass
class Ball:
    x: float = 100

cm.trace(move, Ball())

Any object that copy.deepcopy handles works; a list of Items is the usual state for things that move, and a dict is fine for a quick sketch.

snapshot(state) returns the part worth showing, if a deep copy of the whole state is not what you want. Items keep their identity through the default copy.

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: emit("compare", items=[a, b]). Empty for the opening moment.

class Scene(codimate._surface._SceneSurface, 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 Group: Group.rect, Group.circle, Group.text, Group.line, and Group.formula. Use Group.group to put several shapes 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, pad=40.0, least=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(codimate._surface._GroupSurface):

Somewhere to draw, with its own origin and its own name.

A Group supplies a shared origin and puts its name before each child's name. It does not add an inherited transform; use a Character for connected, poseable parts.

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, *, h, w=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, *, r, at=None) -> Handle:

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

def text( self, key, content, *, size=16, font=None, at=None) -> Handle:

Text, centred horizontally. You never deal with baselines.

scene.text("title", "Bubble sort", size=48, font="fonts/Inter-Bold.ttf")

font is a .ttf/.otf file or an installed font's name, for this text only; unset, the film's font (cm.font).

def line(self, key, *, start, end, w=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 arrow( self, key, *, start, end, w=4.0, head=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 polygon(self, key, points, *, closed=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, points, *, w=2.0, closed=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 arc(self, key, *, 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 group( self, key, slot=None, *, at=None, anchor=None, w=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, at=cm.at(bottom=0))
bar.text("label", item.value, at=cm.at(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.

def formula(self, key, latex, *, size=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. Rendering uses the bundled ffmpeg.

def image(self, key, 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, 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 character( self, key, file, *, size=None, at=None) -> Character:

A poseable figure from a rigged SVG.

guide = scene.character("guide", cm.characters.codimate, size=360,
                         at=(640, 360))
guide.part("right-arm").turn(-25)
guide.expression("thinking")

Use cm.characters.codimate for the bundled mascot, or give the path to your own SVG. Mark joint groups with an id and data-pivot="x y"; the pivot is measured in the root SVG's viewBox. Nested joints carry their children. Put named facial variants in groups with data-expression="name", and optionally set data-default-expression="name" on the rig root. Set the pose in each Scene; Codimate animates between them.

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

A highlighter stroke over any box on screen.

scene.marker("note", w=260, h=70, at=(640, 300)).turn(-8)
scene.marker("note", w=260, h=70, at=(640, 300)).write(reveal=0.5)

It is not tied to text. .write(reveal=0..1) draws it on from its left end, and .turn(degrees) sets its angle and so the way it draws. Colour with .fill(name): yellow, orange, pink, green, blue or purple, or any #rrggbb. It is slightly translucent and sits under layer 10, where the words go. Drawn by hand: straight edges, a slight tilt and chisel-cut ends, the same every time for the same name.

def highlight( self, key, content, *, size=16.0, font=None, at=None) -> tuple[Handle, Handle]:

Words under a highlighter stroke, the stroke sized to them.

band, words = scene.highlight("pivot", "Pivot", size=30, at=(640, 360))
band.write(reveal=0.0)       # rubbed out; the words turn white
words.fill("#171717")        # on a white page, keep them dark

A marker over the words' measured box, padded by a quarter of size on every side, with the words drawn as their own text shape on top. Marked, the words are near-black; band.write(reveal=...) blends them back to their own colour as the stroke rubs out, in step with it. Their own colour is white unless words.fill(...) says otherwise. font is as for text, and the stroke is sized to the words in it. Give each word its own name and keep its words fixed under that name: the stroke can then move and draw on or off between words without the letters overflowing it.

class Handle(codimate._surface._HandleSurface):

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. Every method is worked out by the Authoring Core (the generated methods of _HandleSurface); this keeps the answers.

def fill( self, color='white', *, edge=None, edge_w=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, pivot='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=None, opacity=None) -> Handle:

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

def round(self, radius) -> Handle:

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

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

How much of a formula or an imported SVG shows, and whether a pen draws it on. On a marker, how far its stroke has been drawn 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.

class Character(codimate.Handle):

A connected figure whose pose belongs to this Scene.

def theme(self, name: str) -> Character:

Choose the SVG's light or dark palette for this Scene.

def part(self, name: str) -> CharacterPart:

Select a joint by the id of its marked SVG group.

def expression( self, name: str, *, transition: str = 'crossfade') -> Character:

Choose an expression, crossfading or cutting to it between Scenes.

class CharacterPart:

A joint handle. Its children follow when its angle changes.

def turn(self, degrees: float) -> CharacterPart:

Set this joint's angle in degrees for the current Scene.

class TextPreset:

Draw text as individually moving words or letters.

Use a named helper such as codimate.text.words_up for a fixed motion. This class is the configurable form when the preset name is data::

title = cm.TextPreset(
    "title", "Hello world", preset, style=cm.TextPresetStyle(size=72)
)

def view(frame):
    scene = cm.Scene()
    title.draw(scene, state="shown" if frame.state.ready else "hidden")
    return scene

cm.explain(..., motion=title.rules(stagger=0.12))

preset is one of words_up, words_down, letters_up, letters_down, words_left, words_right, or words_up_blur.

state is "hidden" or "shown". For words_up_blur, it may also be "blurred"; switch from that state to "shown" to resolve the soft halo into sharp text. The halo uses small translucent text copies, so it needs no image or optional imaging dependency.

at is the text block's centre (a point or Slot); line breaks are centred independently. letters_* keeps combining marks and joined emoji together as one moving unit.

def draw(self, group, *, state='hidden'):

Draw this preset into a Scene or Group at one of its stable states.

def rules(self, *, ease='out', stagger=0.12, order='screen'):

Return motion rules for this preset's words, letters, and blur.

class TextPresetStyle:

Text appearance and travel distance shared by a preset's units.

size is in canvas points. font and fill are optional, and distance is how far each word or letter starts from its final place.

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

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

def font(name: str = None) -> None:

Set the font every text in the film uses, unless it names its own.

cm.font("fonts/Inter-Regular.ttf")    # a .ttf or .otf file
cm.font("Helvetica Neue")             # or an installed font, by name
cm.font()                             # back to the built-in font

A name with a / or a font file's extension is a file; anything else is looked up among the fonts installed on this machine. A file travels with your project and renders the same everywhere, so prefer one for a film you will share. Bold or italic is its own file or name: "Inter Bold". Characters the font lacks are drawn in the built-in fonts, so Khmer and Chinese still show. Call it before building scenes, like canvas. A font that will not load is a ValueError here, not a surprise at render.

def measure(text, size=16.0, font=None):

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", w=w + 24, h=h + 12, at=(x, y)).round(6)
scene.text("label", label, size=30, at=(x, y))

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.

font is measured in, as for scene.text; unset, the film's font.

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=40.0, size=None, at=None, within=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=40.0, size=None, at=None, within=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 axes(*, x, y, at=None, size=None, within=None) -> Axes:

A coordinate map from your numbers to pixels.

plot = cm.axes(x=(-4, 4), y=(-2, 6), size=(760, 420)).draw(scene)
scene.curve("f", plot.line(lambda t: t * t), w=4).fill("orange")
scene.circle("dot", r=8, at=plot.at(t, t * t))

x and y are (low, high). size is (w, h) in pixels, or one number for a square; it defaults to most of the canvas. at places the box's centre, and within=slot fits it to part of the canvas.

It returns points rather than drawing, so what you plot is a shape with a name of yours — which is what lets it tween, be framed by focus, and be aimed at by a motion Rule. See ADR 0016.

class Axes:

A map from your numbers to pixels. Built by axes().

It draws nothing you did not ask it to and owns none of your names — see at(). Its own shapes (frame, ticks, labels) are drawn by draw(), under names you choose the prefix of.

def looks(self, ink=None, label=None, layer=None):

Restyle the frame, ticks and labels. Returns a new Axes.

plot = cm.axes(x=(0, 10), y=(0, 5)).looks(ink="#334", label=16)

A separate call rather than more arguments on draw(), which the shapes made the same choice about: no call in this library takes more than five things.

def at(self, x, y):

One data point as a pixel pair, for any shape's at=.

scene.circle("dot", r=8, at=plot.at(2.0, 4.0))

Handing back pixels rather than drawing is the whole design: what you do with them is an ordinary shape with a name of yours, so it tweens, focus frames it, and a motion Rule can be aimed at it.

def line(self, f, steps=200, over=None):

steps + 1 points along y = f(x), as pixels.

scene.curve("f", plot.line(lambda x: x * x), w=4).fill("red")

over=(lo, hi) samples part of the range instead of all of it.

An open curve is drawn rather than filled, so its colour is fill() and its thickness is w= — fill("none", edge=...) draws nothing at all.

Every sample is returned, including any that fall outside the box. Dropping them would be prettier and is wrong: two curves with different point counts do not interpolate (ADR 0010), so a curve that shed a point as it left the frame would stop animating. Keep the count and clamp the range with over= if you need it inside.

def ticks(self, axis='x', about=6):

(n, value, label) per tick, where n is the step multiple.

n is what a tick is named after, not the value. A float that drifts by one part in a billion is a different name, and a renamed shape leaves and re-enters — which is a fade, on a picture that did not move. An integer count of steps cannot drift.

def draw(self, group, name='plot', *, about=6, grid=False):

Draw the frame, ticks and labels. Returns the Axes, for chaining.

plot = cm.axes(x=(-4, 4), y=(-2, 6)).draw(scene)

Every shape is named (name, ...), so two plots on one canvas do not collide and you can restyle or omit any of them by drawing your own. Colours and sizes come from looks().

def ngon(sides, r, *, at=(0.0, 0.0), turn=0.0):

The corners of a regular polygon, for Group.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, r, *, inner=None, at=(0.0, 0.0), turn=0.0):

The corners of a star, for Group.polygon.

scene.polygon("s", cm.star(5, r=80, at=(640, 360))).fill("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 change. First matching rule wins.

cm.Rule("*", position="lift_carry_drop", clearance=90)
cm.Rule("bar/*", ease="spring", stagger=0.08)
cm.Rule("card", position="arc", bend=-0.4, ease="out")

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.
  • arc — a curve bowed to one side. bend is how far, as a fraction of the distance travelled (default 0.3); negative bows the other way.

Timing and feel, for everything about the shape — where it is, its size, colour, opacity, turn, and a marker's reveal — not only its position:

  • ease — linear, in, out, in_out, back (overshoots and settles), spring (takes bounciness 0..1) or bounce. Unset, a shape moves as it always has: eased position, the rest linear.
  • delay — wait this fraction of the beat before starting.
  • stagger — matching shapes start this fraction of the beat apart, in order: screen (left to right, then top to bottom; the default), reverse, name or centre (from the middle out). They all take the same time, and the last one finishes as the beat ends.
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):

The easing curve the Engine applies between two moments. Worked out by the Authoring Core; see ease in api/surface.toml.

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 cm.trace(fn, state) 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(codimate._surface._ExplanationSurface):

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, index: bool | str = 'auto') -> 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.

index decides what is written beside the video. "auto" (or True) writes <name>.index.bin, the compact index: a few megabytes for a film whose readable index would be hundreds, written a chunk at a time, so its size and the memory it takes do not depend on the film. "json" writes the readable <name>.index.json instead, which is as large as it looks. False writes neither. See codimate.index.

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

Every clip, as (start, path): emit("said", sound="said.wav") starts said.wav as the beat for "said" begins.

The start is read off timeline(), so a sound cannot disagree with the picture: both are timed by the same events. Sound is never part of a Scene (ADR 0007) — it rides on the event, beside it.

def mix_sound(self, output: str) -> str | None:

Every clip laid at its start and summed, as one audio file — what render puts under the picture, without the picture. None if nothing makes a sound.

def index_size(self) -> int:

About how many bytes write_index() would write, without building it: shapes that arrive or move, at what the index measured per entry. Cheap beside a render, and close enough to know whether the readable index (index="json") is worth asking for.

def write_index(self, output: str) -> str:

Write index() beside output, as <name>.index.json.

The file wraps the beats with the canvas size — without it a click on the video cannot be turned back into the coordinates the boxes are in — and a version, because other tools read this and it will change.

def write_compact_index(self, output: str) -> str:

Write the index beside output as <name>.index.bin.

The same facts as write_index(), kept to 1/16 of a pixel and stored as columns of small integers: see codimate.index. Written a chunk at a time, so it does not first build the whole index in memory.

def sheet( self, times, output: str = 'sheet.png', *, columns: int = 3, scale: float = 0.5) -> str:

Several moments at once, tiled into one picture.

cm.explain(...).sheet([8, 22, 54, 96], "look.png")

frame_at answers "what does 0:54 look like"; this answers "does the whole thing hang together", which is the question actually being asked when somebody renders a film and scrubs through it. The tiling is done by the ffmpeg that rendering already needs.

scale shrinks the sheet, and does the shrinking in ffmpeg rather than in the Engine, which will not rasterize below 1:1 — so a whole film fits on a screen instead of arriving four thousand pixels wide.

def export_web(self, output: str) -> str:

Save the film for the browser, where codimate-js plays it.

cm.explain(...).export_web("film.json")

The file is what render() hands the Engine (every scene, the cameras, the motion rules and the durations), so the browser draws the same frames the video has, from the same Rust code. Nothing is rendered here. fonts lists the fonts the scenes name: the page must provide those before playing. The Authoring Core writes the file, so the same film written from any language is the same bytes.

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 index(self) -> list[dict]:

Every beat, as data: when, what happened, and what was on screen.

for beat in cm.explain(...)codimate.index:
    print(beat["at"], beat["event"], len(beat["shapes"]))

One entry per beat, in step with timeline(), carrying the State behind it — so a question like "what is at 0:55, and what put it there?" has an answer without rendering a frame and squinting at it.

Shapes are a difference, not a list. Each beat carries only what changed: shapes["set"] is the ones that arrived or moved, and shapes["gone"] the names that left. Apply them in order from the first beat to know what is on screen at any of them. A whole list per beat is the obvious format and it is 94% repetition — in a hundred second film that is seven megabytes of the same rectangle.

covered lists the [above, below] pairs where something is drawn on a label at that beat (ADR 0017), and chapter is there only on a beat that starts one (chapters()).

A shape's box is None only for a formula on a machine without Typst, which is what measures one (ADR 0005).

render writes it beside the video in a compact form, a few megabytes however dense the film, and index="json" writes this as JSON (ADR 0018, ADR 0020).

def timeline(self):

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.

def chapters(self):

Where each part of the film starts, as (start, name): emit("water", chapter="Water") starts one as that beat begins.

A film is hundreds of events — a highlighted word is one — so the Previewer cannot show a picture of each. A chapter is the author saying where a part begins, which nothing downstream could work out.

def covered(self):

Every label something was drawn on: (seconds, above, below).

Checked once per Trace Event, on the picture the event settles into (ADR 0017). A collision that lasts is reported where it starts, not at every beat it survives.

def report(self):

covered(), worded — what render prints when it is not empty.