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 anemit, to 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,image, or a poseablecharacterngon,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(), chainedCharacter— a connected SVG figure with per-Scene joints and expressionsTextPreset,TextPresetStyle— reusable word and letter entrances
Where things sit
canvas,width,height— the framefont— the font every text uses, by file or installed namerow,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
Explaining physics
science— labels, glows, brackets, force arrows; a camera, spheres, orbits
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.
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: 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.
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.
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.
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).
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).
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).
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 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.
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 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.
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.
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 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.
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.
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.
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.
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. 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.
A connected figure whose pose belongs to this Scene.
A joint handle. Its children follow when its angle changes.
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.
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.
Set the size of the video. Defaults to 1280x720.
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.
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.
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.
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.
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.
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.
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.
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.
(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.
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().
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.
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.
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. Takesclearance.arc— a curve bowed to one side.bendis 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(takesbounciness0..1) orbounce. 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, inorder:screen(left to right, then top to bottom; the default),reverse,nameorcentre(from the middle out). They all take the same time, and the last one finishes as the beat ends.
How long each event lasts, in seconds.
The easing curve the Engine applies between two moments. Worked out by
the Authoring Core; see ease in api/surface.toml.
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().
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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).
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.
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.