Animation
Easing, keyframes, timing, motion paths and procedural motion.
Easings
Every Easing preset, curve family, overshoot mode, spring and shaping factory, racing side by side.

gaanim examples/animation/easings.pyCode · 138 lines
"""Easing: named presets, curve families, overshoot, springs and shaping factories.
Every dot crosses the same track in the same time, so only the easing differs.
Each segment covers one part of the Easing API, and each label is the code
that builds the easing.
"""
import os
from gaanim import Easing, EasingCurve, Scene, Zones
DURATION = 1.6
HOLD = 0.4
scene = Scene()
rule = scene.canvas.color("rule")
def inner(zone):
"""Leave a tenth of ``zone`` free at each end, room for easings that overshoot."""
return zone.inset((0, zone.width * 0.1))
def track(zone, easing):
"""Draw a track across ``zone`` and return the move of its dot."""
(start, y), (end, _) = zone.point("left"), zone.point("right")
scene.geometry.line(start, y, end, y).stroke(rule, 0.02)
dot = scene.geometry.dot(0.12).move_to(start, y)
return dot.animate.move_to(end, y).duration(DURATION).easing(easing)
def table(rows, columns=None, build=None):
"""Race one dot per cell, with each row's label to the left of its tracks.
``rows`` are (label, value) pairs. Without ``columns``, each row's value is
its easing. Otherwise ``columns`` are (label, value) pairs too, labelled
in a header row, and ``build(row_value, column_value)`` makes each cell's
easing.
"""
header = [] if columns is None else columns
columns = columns or [(None, None)]
tracks = ["4fr"] + ["3fr"] * len(columns)
grid = scene.layout.zones(
Zones.grid(rows=len(rows) + bool(header), columns=tracks, column_gap="48px", padding="page_padding")
)
for index, (label, _) in enumerate(header, start=1):
scene.text(label, role="code").place(inner(grid[index]), anchor="left")
moves = []
for row_index, (label, row) in enumerate(rows, start=bool(header)):
first = row_index * len(tracks)
scene.text(label, role="code").place(grid[first], anchor="right")
for index, (_, column) in enumerate(columns, start=1):
easing = row if build is None else build(row, column)
moves.append(track(inner(grid[first + index]), easing))
scene.play(moves)
scene.wait(HOLD)
scene.segment("presets")
table([
("Easing.LINEAR", Easing.LINEAR),
("Easing.SMOOTH", Easing.SMOOTH),
("Easing.DOUBLE_SMOOTH", Easing.DOUBLE_SMOOTH),
("Easing.LINGERING", Easing.LINGERING),
("Easing.RUNNING_START", Easing.RUNNING_START),
("Easing.EXPONENTIAL_DECAY", Easing.EXPONENTIAL_DECAY),
("Easing.NOT_QUITE_THERE", Easing.NOT_QUITE_THERE),
("Easing.THERE_AND_BACK", Easing.THERE_AND_BACK),
])
# Every EasingCurve family eases in, out, or both ways: Easing.ease_in(EasingCurve.CUBIC).
scene.segment("curves")
CURVES = ["QUADRATIC", "CUBIC", "QUARTIC", "QUINTIC", "EXPONENTIAL", "SINE", "CIRCULAR", "BACK", "ELASTIC", "BOUNCE"]
table(
[(name, getattr(EasingCurve, name)) for name in CURVES],
[("ease_in", Easing.ease_in), ("ease_out", Easing.ease_out), ("ease_in_out", Easing.ease_in_out)],
lambda curve, ease: ease(curve),
)
# back, elastic and bounce take a strength and the same three modes:
# Easing.back(3.0, mode="in_out").
scene.segment("overshoot")
table(
[
("back()", lambda mode: Easing.back(mode=mode)),
("back(3.0)", lambda mode: Easing.back(3.0, mode=mode)),
("elastic()", lambda mode: Easing.elastic(mode=mode)),
("elastic(1.5, 0.5)", lambda mode: Easing.elastic(1.5, 0.5, mode=mode)),
("bounce()", lambda mode: Easing.bounce(mode=mode)),
("bounce(0.4)", lambda mode: Easing.bounce(0.4, mode=mode)),
],
[('mode="in"', "in"), ('mode="out"', "out"), ('mode="in_out"', "in_out")],
lambda factory, mode: factory(mode),
)
# Perceptual presets settle within the animation; physical springs take
# stiffness, damping, mass and an initial velocity.
scene.segment("springs")
table([
("Easing.SMOOTH_SPRING", Easing.SMOOTH_SPRING),
("Easing.GENTLE", Easing.GENTLE),
("Easing.QUICK", Easing.QUICK),
("Easing.SNAPPY", Easing.SNAPPY),
("Easing.BOUNCY", Easing.BOUNCY),
("Easing.spring(bounce=0.35)", Easing.spring(bounce=0.35)),
("Easing.spring(160, 8, mass=2)", Easing.spring(160, 8, mass=2)),
("Easing.spring(90, 12, velocity=3)", Easing.spring(90, 12, velocity=3)),
])
# Factories that reshape time: holds, steps, jitter, and curves of your own.
scene.segment("shaping")
OVERSHOOT_PATH = "M0,0 C0.3,0 0.2,1.25 1,1"
def cube(t):
"""A function of t in [0, 1] for Easing.custom, which samples it into a table."""
return t**3
table([
("Easing.slow_mo(0.6)", Easing.slow_mo(0.6)),
("Easing.rough(0.8, seed=4)", Easing.rough(0.8, seed=4)),
("Easing.steps(4)", Easing.steps(4)),
('Easing.steps(6, jump="both")', Easing.steps(6, jump="both")),
("Easing.squish(SMOOTH, 0.3, 0.7)", Easing.squish(Easing.SMOOTH, 0.3, 0.7)),
("Easing.mirror(SMOOTH)", Easing.mirror(Easing.SMOOTH)),
("Easing.there_and_back(pause=0.3)", Easing.there_and_back(pause=0.3)),
("Easing.cubic_bezier(0.7, 0, 0.3, 1)", Easing.cubic_bezier(0.7, 0, 0.3, 1)),
("Easing.from_svg(OVERSHOOT_PATH)", Easing.from_svg(OVERSHOOT_PATH)),
("Easing.custom(cube, samples=6)", Easing.custom(cube, samples=6)),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Early, mid-race and settled, in each of the five segments.
scene.snapshots(snapshots, [(DURATION + HOLD) * index + t for index in range(5) for t in (0.5, 1.0, 1.9)])
else:
scene.render()Entrances exits
Every built-in entrance and exit: fades from each direction, stroke drawing, growth from a center, point or edge, and the exits that hide a drawable.

gaanim examples/animation/entrances_exits.pyCode · 135 lines
"""Entrances and exits: every built-in way to bring a drawable in and take it out.
Each row plays one animation of ``drawable.animate`` and is labelled with the
call. Entrances keep the drawable hidden until they start; exits leave it
hidden when they end. One segment per family: fades, strokes, growth, exits.
"""
import os
from gaanim import Direction, Scene, Zones
DURATION = 1.2
HOLD = 0.5
scene = Scene()
foreground = scene.canvas.color("foreground")
rule = scene.canvas.color("rule")
def card(zone):
"""An outlined card in the middle of ``zone``."""
return scene.geometry.rounded_rect(1.8, 0.56, 0.1).stroke(foreground, 0.03).move_to(*zone.center)
def star(zone):
"""An outlined star in the middle of ``zone``."""
return scene.geometry.star(5, 0.36, 0.16).stroke(foreground, 0.03).move_to(*zone.center)
def word(zone):
"""A word of text in the middle of ``zone``."""
return scene.text("Hello").move_to(*zone.center)
def arrow(zone):
"""An arrow that bends across the middle of ``zone``."""
(x, y), half = zone.center, 1.2
return scene.geometry.curved_arrow(x - half, y - 0.15, x + half, y - 0.15, 0.8)
def trio(zone):
"""Three dots grouped in the middle of ``zone``."""
x, y = zone.center
return scene.geometry.group([scene.geometry.circle(0.2).move_to(x + dx, y) for dx in (-0.7, 0, 0.7)])
def rows(count):
"""``count`` rows of a label column and a stage column, padded from the frame."""
return scene.layout.zones(Zones.grid(rows=count, columns=["4fr", "3fr"], column_gap="48px", padding="page_padding"))
def table(entries, hold_first=False):
"""Play one animation per row, its code to the left of the drawable it animates.
``entries`` are (label, make, animation): ``make(zone)`` builds the drawable
in the row's stage and ``animation(drawable.animate)`` returns its
animation. With ``hold_first`` the drawables show for a moment first, as exits need.
"""
grid = rows(len(entries))
moves = []
for index, (label, make, animation) in enumerate(entries):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
moves.append(animation(make(grid[2 * index + 1]).animate).duration(DURATION))
if hold_first:
scene.wait(HOLD)
scene.play(moves)
scene.wait(HOLD)
# Fades change opacity; fade_in_from also slides in from a side, 0.48 units by default.
scene.segment("fades")
table([
("fade_in()", card, lambda a: a.fade_in()),
("fade_in_from(Direction.UP)", card, lambda a: a.fade_in_from(Direction.UP)),
("fade_in_from(Direction.DOWN)", card, lambda a: a.fade_in_from(Direction.DOWN)),
("fade_in_from(Direction.LEFT)", card, lambda a: a.fade_in_from(Direction.LEFT)),
("fade_in_from(Direction.RIGHT)", card, lambda a: a.fade_in_from(Direction.RIGHT)),
("fade_in_from(Direction.UP_LEFT)", card, lambda a: a.fade_in_from(Direction.UP_LEFT)),
("fade_in_from(Direction.DOWN_RIGHT)", card, lambda a: a.fade_in_from(Direction.DOWN_RIGHT)),
("fade_in_from(Direction.LEFT, 2.0)", card, lambda a: a.fade_in_from(Direction.LEFT, 2.0)),
])
# Stroke entrances trace the outline, then fill it. stroke_width sets the
# traced width, with_pen_tip rides a dot on the drawing end, and lag_ratio
# staggers the members of a group.
scene.segment("strokes")
table([
("create()", star, lambda a: a.create()),
("create().stroke_width(0.08)", star, lambda a: a.create().stroke_width(0.08)),
("create().with_pen_tip()", star, lambda a: a.create().with_pen_tip()),
("draw_border_then_fill()", star, lambda a: a.draw_border_then_fill()),
("write()", word, lambda a: a.write()),
('write(brush="chalk", seed=3)', word, lambda a: a.write(brush="chalk", seed=3)),
("create().lag_ratio(0.0)", trio, lambda a: a.create().lag_ratio(0.0)),
("create().lag_ratio(1.0)", trio, lambda a: a.create().lag_ratio(1.0)),
])
# Growth scales up from zero about a fixed point: the center, a scene point,
# an edge or corner of the bounds, or an arrow's tail.
scene.segment("growth")
# The pinned point of grow_from_point: the left end of its row's stage.
px, py = rows(7)[5].point("left")
scene.geometry.dot(0.05).fill(rule).move_to(px, py)
table([
("grow_from_center()", card, lambda a: a.grow_from_center()),
("spin_in_from_nothing()", star, lambda a: a.spin_in_from_nothing()),
(f"grow_from_point({px:.2f}, {py:.2f})", card, lambda a: a.grow_from_point(px, py)),
("grow_from_edge(Direction.DOWN)", card, lambda a: a.grow_from_edge(Direction.DOWN)),
("grow_from_edge(Direction.LEFT)", card, lambda a: a.grow_from_edge(Direction.LEFT)),
("grow_from_edge(Direction.UP_RIGHT)", card, lambda a: a.grow_from_edge(Direction.UP_RIGHT)),
("grow_arrow()", arrow, lambda a: a.grow_arrow()),
])
# Exits leave the drawable hidden: fade_out, the reverse of create and write,
# and a shrink to the center.
scene.segment("exits")
table([
("fade_out()", card, lambda a: a.fade_out()),
("uncreate()", star, lambda a: a.uncreate()),
("uncreate().stroke_width(0.08)", star, lambda a: a.uncreate().stroke_width(0.08)),
("unwrite()", word, lambda a: a.unwrite()),
("shrink_to_center()", card, lambda a: a.shrink_to_center()),
], hold_first=True)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Early, mid-way and settled in the three entrance segments, then the
# exits held, mid-way and gone.
scene.snapshots(
snapshots,
[(DURATION + HOLD) * index + t for index in range(3) for t in (0.3, 0.7, 1.4)]
+ [3 * (DURATION + HOLD) + t for t in (0.3, 1.0, 1.4, 2.0)],
)
else:
scene.render()Properties
Every property target of drawable.animate: position, transforms, style, polygon points and custom animations computed by your own function.

gaanim examples/animation/properties.pyCode · 215 lines
"""Property animations: every target ``drawable.animate`` can move, transform, restyle or compute.
Each row animates one shape from the state its outline marks, and is labelled
with the call chained on ``shape.animate``. Segments cover position, transforms,
style, and custom animations computed by your own function.
"""
import math
import os
from gaanim import Anchor, Brush, Scene, Zones
DURATION = 1.4
HOLD = 0.6
scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
rule = scene.canvas.color("rule")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
def table(entries):
"""One row per entry, its code to the left: ``build(stage)`` returns the row's animation."""
grid = scene.layout.zones(
Zones.grid(rows=len(entries), columns=["5fr", "2fr"], column_gap="48px", padding="page_padding")
)
moves = []
for index, (label, build) in enumerate(entries):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
moves.append(build(grid[2 * index + 1].inset((0, "48px"))))
scene.play(moves, duration=DURATION)
scene.wait(HOLD)
def mark(x, y):
"""A small dot marking the scene point ``(x, y)``."""
scene.geometry.dot(0.05).fill(rule).move_to(x, y)
def square(x, y, size=0.5):
"""A square at ``(x, y)`` over an outline that keeps its starting place."""
scene.geometry.square(size).no_fill().stroke(rule, 0.02).move_to(x, y)
return scene.geometry.square(size).move_to(x, y)
def plane(x, y):
"""A paper plane pointing along +x at ``(x, y)``, over an outline of where it starts."""
points = [(0.32, 0.0), (-0.22, 0.18), (-0.1, 0.0), (-0.22, -0.18)]
scene.geometry.polygon(points).no_fill().stroke(rule, 0.02).move_to(x, y)
return scene.geometry.polygon(points).move_to(x, y)
def start(zone):
"""The point near the left end of ``zone`` where moving shapes start."""
return zone.left + 0.4, zone.center[1]
# Position: a point (with the anchor that lands on it), another drawable or
# one of its anchor points, a relative shift, or a layout zone.
def moving_to(zone):
x, y = zone.right - 0.4, zone.center[1] + 0.2
mark(x, y)
return square(*start(zone)).animate.move_to(x, y)
def anchored(zone):
x, y = zone.center
mark(x, y)
return square(*start(zone)).animate.move_to(x, y, anchor=Anchor.LEFT)
def to_target(zone):
target = scene.geometry.circle(0.3).no_fill().stroke(foreground, 0.02).move_to(zone.right - 0.5, zone.center[1])
return square(*start(zone)).animate.move_to(target)
def to_anchor(zone):
target = scene.geometry.circle(0.3).no_fill().stroke(foreground, 0.02).move_to(zone.right - 0.5, zone.center[1])
return square(*start(zone)).animate.move_to(target.anchor_point(Anchor.LEFT))
scene.segment("position")
table([
("move_to(x, y)", moving_to),
("move_to(x, y, anchor=Anchor.LEFT)", anchored),
("move_to(target)", to_target),
("move_to(target.anchor_point(Anchor.LEFT))", to_anchor),
("shift_by(2.0, 0.3)", lambda zone: square(*start(zone)).animate.shift_by(2.0, 0.3)),
('place(stage, anchor="right")', lambda zone: square(*start(zone)).animate.place(zone, anchor="right")),
])
# Transforms: uniform or per-axis scale, absolute or relative rotation (about
# the shape's pivot or any scene point), shear, and a general linear map.
def orbiting(zone):
x, y = zone.center
mark(x - 0.45, y)
return plane(x, y).animate.rotate_by(math.pi).about_point(x - 0.45, y)
scene.segment("transform")
table([
("scale_to(1.8)", lambda zone: square(*zone.center).animate.scale_to(1.8)),
("scale_by(0.5)", lambda zone: square(*zone.center).animate.scale_by(0.5)),
("scale_to_3d(2.5, 0.5, 1.0)", lambda zone: square(*zone.center).animate.scale_to_3d(2.5, 0.5, 1.0)),
("rotate_to(math.pi / 4)", lambda zone: plane(*zone.center).animate.rotate_to(math.pi / 4)),
("rotate_by(-math.pi / 2)", lambda zone: plane(*zone.center).animate.rotate_by(-math.pi / 2)),
("rotate_by(math.pi).about_point(x, y)", orbiting),
("skew_to(0.6, 0)", lambda zone: square(*zone.center).animate.skew_to(0.6, 0)),
("matrix_to(((1, 0.5), (0.3, 1)))", lambda zone: square(*zone.center).animate.matrix_to(((1, 0.5), (0.3, 1)))),
])
# Style: opacity, a fill color or gradient, a stroke appearing or widening,
# the vertices of a polygon, and several targets sharing one animation.
def card(zone):
"""A card in the middle of ``zone`` over an outline of its starting shape."""
scene.geometry.rounded_rect(1.8, 0.5, 0.08).no_fill().stroke(rule, 0.02).move_to(*zone.center)
return scene.geometry.rounded_rect(1.8, 0.5, 0.08).move_to(*zone.center)
def reshaping(zone):
x, y = zone.center
triangle = [(x - 0.9, y - 0.25), (x + 0.9, y - 0.25), (x, y + 0.25)]
flipped = [(x - 0.9, y + 0.25), (x + 0.9, y + 0.25), (x + 0.6, y - 0.25)]
scene.geometry.polygon(triangle).no_fill().stroke(rule, 0.02)
return scene.geometry.polygon(triangle).animate.points(flipped)
def combined(zone):
x, y = zone.right - 0.5, zone.center[1]
mark(x, y)
return square(*start(zone)).animate.move_to(x, y).scale_to(1.4).fill(danger)
gradient = Brush.linear([accent, danger], start=(-0.9, 0), end=(0.9, 0))
scene.segment("style")
table([
("opacity(0.2)", lambda zone: card(zone).animate.opacity(0.2)),
("fill(success)", lambda zone: card(zone).animate.fill(success)),
("fill(gradient)", lambda zone: card(zone).animate.fill(gradient)),
("stroke(danger, 0.08)", lambda zone: card(zone).animate.stroke(danger, 0.08)),
("stroke_width(0.12)", lambda zone: card(zone).stroke(foreground, 0.02).animate.stroke_width(0.12)),
("points(flipped)", reshaping),
("move_to(x, y).scale_to(1.4).fill(danger)", combined),
])
# custom calls your function with the eased progress and takes the values it
# returns for the declared channels. A Parameter animates with set.
def hopping(zone):
(x, y), distance = start(zone), zone.width - 0.8
def hop(a):
return {"position": (x + distance * a, y + 0.35 * math.sin(math.pi * a))}
return square(x, y).animate.custom(hop, channels=("position",))
def spinning(zone):
def spin(a):
return {"rotation": math.tau * a, "scale": 1 + 0.6 * math.sin(math.pi * a)}
return square(*zone.center).animate.custom(spin, channels=("rotation", "scale"))
def flickering(zone):
def flicker(a):
return {"opacity": 0.6 + 0.4 * math.cos(6 * math.pi * a)}
return card(zone).animate.custom(flicker, channels=("opacity",))
def breathing(zone):
def breathe(a):
return {"stroke_width": 0.02 + 0.12 * math.sin(math.pi * a)}
return card(zone).stroke(foreground, 0.02).animate.custom(breathe, channels=("stroke_width",))
def switching(zone):
def switch(a):
return {"fill": accent if a < 0.5 else success}
return card(zone).animate.custom(switch, channels=("fill",))
def setting(zone):
level = scene.viz.parameter(1.0)
scene.geometry.circle(0.2).no_fill().stroke(rule, 0.02).move_to(*zone.center)
scene.geometry.circle(0.2).move_to(*zone.center).scale_to(level)
return level.animate.set(2.0)
scene.segment("custom")
table([
('custom(hop, channels=("position",))', hopping),
('custom(spin, channels=("rotation", "scale"))', spinning),
('custom(flicker, channels=("opacity",))', flickering),
('custom(breathe, channels=("stroke_width",))', breathing),
('custom(switch, channels=("fill",))', switching),
("level.animate.set(2.0)", setting),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment midway and settled.
scene.snapshots(
snapshots,
[segment * (DURATION + HOLD) + t for segment in range(4) for t in (0.5, 0.9, 1.8)],
)
else:
scene.render()Keyframes
Keyframes on every channel, an easing per segment between stops, and linear versus Catmull-Rom paths through the stops.

gaanim examples/animation/keyframes.pyCode · 142 lines
"""Keyframes: one clip through several stops, on any channel, with an easing per segment.
``animate.keyframes(times, ...)`` takes one value per time for each channel it
animates, and ``None`` keeps the value the drawable has when the clip starts.
The first segment animates every channel, the second the easings between
stops, the third how a position travels between them.
"""
import math
import os
from gaanim import Easing, Scene, Zones
DURATION = 1.6
HOLD = 0.4
scene = Scene()
foreground = scene.canvas.color("foreground")
rule = scene.canvas.color("rule")
success = scene.canvas.color("success")
danger = scene.canvas.color("danger")
def table(header, rows):
"""One row per keyframed drawable, its code to the left of it.
``header`` labels the call the rows share; ``rows`` are (label, build)
pairs, and ``build(zone)`` draws in the row's stage and returns the clip.
"""
grid = scene.layout.zones(
Zones.grid(rows=len(rows) + 1, columns=["4fr", "3fr"], column_gap="48px", padding="page_padding")
)
scene.text(header, role="code").place(grid[0], anchor="right")
clips = []
for index, (label, build) in enumerate(rows, start=1):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
clips.append(build(grid[2 * index + 1].inset((0, "48px"))).duration(DURATION))
scene.play(clips)
scene.wait(HOLD)
def square(zone, anchor="center"):
"""A square at ``anchor`` of ``zone``."""
return scene.geometry.square(0.5).move_to(*zone.point(anchor))
def gauge(zone):
"""A circle scaled by a Parameter, and the keyframes of that Parameter."""
level = scene.viz.parameter(1.0)
scene.geometry.circle(0.25).move_to(*zone.center).scale_to(level)
return level.animate.keyframes([0, 0.5, 1], values=[None, 1.6, 0.6])
# Each channel takes one value per time: displacements from the start (offset=,
# or absolute position= as in the next segments), angles, scale factors
# (uniform or per axis), opacities and paints. On a Parameter, values=
# keyframes the number itself.
channels_start = scene.cursor
scene.segment("channels")
table("keyframes(times=[0, 0.5, 1], ...)", [
("offset=[None, (2.5, 0.4), (5, 0)]",
lambda z: square(z, "left").animate.keyframes([0, 0.5, 1], offset=[None, (2.5, 0.4), (5, 0)])),
("rotation=[None, math.pi / 4, 0]",
lambda z: square(z).animate.keyframes([0, 0.5, 1], rotation=[None, math.pi / 4, 0])),
("scale=[None, 1.6, 1]",
lambda z: square(z).animate.keyframes([0, 0.5, 1], scale=[None, 1.6, 1])),
("scale=[None, (2.4, 0.5), 1]",
lambda z: square(z).animate.keyframes([0, 0.5, 1], scale=[None, (2.4, 0.5), 1])),
("opacity=[None, 0.15, 1]",
lambda z: square(z).animate.keyframes([0, 0.5, 1], opacity=[None, 0.15, 1])),
("fill=[None, success, danger]",
lambda z: square(z).animate.keyframes([0, 0.5, 1], fill=[None, success, danger])),
("stroke=[None, success, danger]",
lambda z: square(z).stroke(foreground, 0.08).animate.keyframes([0, 0.5, 1], stroke=[None, success, danger])),
("values=[None, 1.6, 0.6]", gauge),
])
def track(zone, easing, remap=None):
"""Keyframe a dot through three stops along ``zone``: halfway at 30% of the time."""
(start, y), (end, _) = zone.point("left"), zone.point("right")
middle = (start + end) / 2
scene.geometry.line(start, y, end, y).stroke(rule, 0.02)
for x in (start, middle, end):
scene.geometry.rect(0.02, 0.24).fill(rule).move_to(x, y)
dot = scene.geometry.dot(0.12).move_to(start, y)
clip = dot.animate.keyframes([0, 0.3, 1], position=[None, (middle, y), (end, y)], easing=easing)
return clip if remap is None else clip.easing(remap)
# The easing of each segment between stops: one for every segment, or a list
# with one per segment. The clip's own .easing() remaps the whole clip instead,
# so the stops no longer land at their times.
easing_start = scene.cursor
scene.segment("easing")
table("keyframes(times=[0, 0.3, 1], ...)", [
("easing=None", lambda z: track(z, None)),
("easing=Easing.SMOOTH", lambda z: track(z, Easing.SMOOTH)),
("easing=[Easing.SMOOTH, Easing.BOUNCY]", lambda z: track(z, [Easing.SMOOTH, Easing.BOUNCY])),
("easing=[Easing.LINEAR, Easing.steps(4)]", lambda z: track(z, [Easing.LINEAR, Easing.steps(4)])),
("easing=[Easing.QUICK, Easing.LINGERING]", lambda z: track(z, [Easing.QUICK, Easing.LINGERING])),
("...).easing(Easing.SMOOTH)", lambda z: track(z, None, Easing.SMOOTH)),
])
# spatial= sets how position travels between stops: straight lines, or a
# smooth Catmull-Rom curve through every stop.
spatial_start = scene.cursor
scene.segment("spatial")
panels = scene.layout.zones(
Zones.grid(rows=["1fr", "8fr"], columns=2, column_gap="96px", row_gap="24px", padding="page_padding")
)
clips = []
for column, mode in enumerate(["linear", "catmull_rom"]):
scene.text(f'spatial="{mode}"', role="code").place(panels[column], anchor="center")
stage = panels[2 + column].inset("48px")
stops = [
(stage.left + stage.width * fx, stage.bottom + stage.height * fy)
for fx, fy in [(0.0, 0.1), (0.25, 0.9), (0.5, 0.1), (0.75, 0.9), (1.0, 0.1)]
]
for x, y in stops:
scene.geometry.dot(0.06).fill(rule).move_to(x, y)
ball = scene.geometry.circle(0.22).move_to(*stops[0])
trail = scene.geometry.traced_path(ball).stroke(foreground, 0.02)
clips.append(trail.animate.fade_in().duration(0.1))
clips.append(
ball.animate.keyframes([0, 0.25, 0.5, 0.75, 1], position=[None, *stops[1:]], spatial=mode).duration(2.4)
)
scene.play(clips)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Channels and easings early, at the middle stop, late and settled; the
# spatial modes a quarter, half and all the way through.
scene.snapshots(
snapshots,
[channels_start + t for t in (0.4, 0.8, 1.2, 1.9)]
+ [easing_start + t for t in (0.3, 0.6, 1.0, 1.9)]
+ [spatial_start + t for t in (0.7, 1.3, 2.0, 2.7)],
)
else:
scene.render()Timing
Repeats and loops, parallel, sequence and stagger, reshaping a composition's time, and staggered waves and distributed values from every origin.

gaanim examples/animation/timing.pyCode · 164 lines
"""Timing: repeats and loops, parallel, sequence and stagger, and reshaping a composition's time.
Each row is labelled with the code that times it. The first segment repeats
one move, the second composes five growing bars, the third reshapes the time of
one sequence, and the fourth staggers a grid of dots from every origin.
"""
import os
from gaanim import Direction, Easing, Scene, Zones, distribute, parallel, sequence, stagger
CYCLE = 0.6
GROW = 0.3
HOLD = 0.5
scene = Scene()
rule = scene.canvas.color("rule")
def rows(count, label_share="4fr", stage_share="3fr"):
"""``count`` rows of a label column and a stage column, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=[label_share, stage_share], column_gap="48px", padding="page_padding")
)
def track(zone):
"""Draw a track across ``zone`` with a tick every third, and return its dot and step."""
(start, y), (end, _) = zone.point("left"), zone.point("right")
scene.geometry.line(start, y, end, y).stroke(rule, 0.02)
step = (end - start) / 3
for index in range(4):
scene.geometry.rect(0.02, 0.24).fill(rule).move_to(start + index * step, y)
return scene.geometry.dot(0.12).move_to(start, y), step
# One cycle is a move a third of the way along the track, which delay holds
# back. repeat plays it a number of times and loop as many times as fit. Each
# cycle restarts the move, except that yoyo and pingpong alternate its
# direction, and an offset loop or a repeated Composition continue from
# where the previous cycle ended.
repeat_start = scene.cursor
scene.segment("repeat")
grid = rows(10, "5fr", "3fr")
scene.text("move = dot.animate.shift_by(step, 0)", role="code").place(grid[0], anchor="right")
cycles = [
("move.delay(0.6)", lambda move: move.delay(0.6)),
("move.repeat(3)", lambda move: move.repeat(3)),
("move.repeat(3, delay=0.3)", lambda move: move.repeat(3, delay=0.3)),
("move.repeat(4, yoyo=True)", lambda move: move.repeat(4, yoyo=True)),
('move.loop("cycle", until=2.4)', lambda move: move.loop("cycle", until=2.4)),
('move.loop("cycle", until=2.4, delay=0.3)', lambda move: move.loop("cycle", until=2.4, delay=0.3)),
('move.loop("pingpong", until=2.4)', lambda move: move.loop("pingpong", until=2.4)),
('move.loop("offset", until=1.8)', lambda move: move.loop("offset", until=1.8)),
("parallel(move).repeat(3)", lambda move: parallel(move).repeat(3)),
]
moves = []
for index, (label, timing) in enumerate(cycles, start=1):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
dot, step = track(grid[2 * index + 1].inset((0, "48px")))
moves.append(timing(dot.animate.shift_by(step, 0).duration(CYCLE)))
scene.play(moves)
scene.wait(HOLD)
def bars(zone, count=5):
"""``count`` bars across ``zone``, and the animations that grow them."""
width, (_, y) = zone.width / (2 * count - 1), zone.center
shapes = [scene.geometry.rect(width, zone.height * 0.8).move_to(zone.left + width * (2 * index + 0.5), y)
for index in range(count)]
return [shape.animate.grow_from_edge(Direction.DOWN) for shape in shapes]
def compose(entries, label_share="5fr", stage_share="2fr"):
"""One row of five bars per entry, grown by ``compose(grows)``, its code to the left."""
grid = rows(len(entries), label_share, stage_share)
plays = []
for index, (label, compose_grows) in enumerate(entries):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
plays.append(compose_grows(bars(grid[2 * index + 1].inset(("12px", 0)))))
# Every grow lasts GROW seconds unless its composition says otherwise.
scene.play(plays, duration=GROW)
scene.wait(HOLD)
# parallel starts every item together, sequence chains them (gap spaces or
# overlaps them), and stagger offsets each start by `each` or spreads the
# starts over `total`, optionally eased.
compose_start = scene.cursor
scene.segment("compose")
compose([
("parallel(*grows)", lambda grows: parallel(*grows)),
("sequence(*grows)", lambda grows: sequence(*grows)),
("sequence(*grows, gap=0.15)", lambda grows: sequence(*grows, gap=0.15)),
("sequence(*grows, gap=-0.2)", lambda grows: sequence(*grows, gap=-0.2)),
("stagger(*grows)", lambda grows: stagger(*grows)),
("stagger(*grows, each=0.25)", lambda grows: stagger(*grows, each=0.25)),
("stagger(*grows, total=1.2)", lambda grows: stagger(*grows, total=1.2)),
("stagger(*grows, total=1.2, easing=Easing.SMOOTH)",
lambda grows: stagger(*grows, total=1.2, easing=Easing.SMOOTH)),
])
# A Composition's time can be delayed, stretched to a span, given defaults,
# sped up or ramped, or remapped through an easing.
reshape_start = scene.cursor
scene.segment("reshape")
compose([
("sequence(*grows)", lambda grows: sequence(*grows)),
("sequence(*grows).delay(0.6)", lambda grows: sequence(*grows).delay(0.6)),
("sequence(*grows).stretch(0.8)", lambda grows: sequence(*grows).stretch(0.8)),
("sequence(*grows).defaults(duration=0.15)", lambda grows: sequence(*grows).defaults(duration=0.15)),
("sequence(*grows).speed_ramp(2.0)", lambda grows: sequence(*grows).speed_ramp(2.0)),
("sequence(*grows).speed_ramp({0: 1, 0.5: 0.3, 1: 1})",
lambda grows: sequence(*grows).speed_ramp({0: 1, 0.5: 0.3, 1: 1})),
("sequence(*grows).time_remap(Easing.SMOOTH)", lambda grows: sequence(*grows).time_remap(Easing.SMOOTH)),
("sequence(*grows).time_remap(Easing.steps(3))", lambda grows: sequence(*grows).time_remap(Easing.steps(3))),
], label_share="4fr", stage_share="1fr")
# With an origin, each dot waits in proportion to its distance from it; grid
# measures in cells instead of positions. distribute spreads values (here,
# sizes) in the same order.
origins_start = scene.cursor
scene.segment("origins")
panels = scene.layout.zones(Zones.grid(rows=3, columns=3, gap="48px", padding="page_padding"))
corner = panels[6].point("bottom_left")
origins = [
("origin=None", {}),
('origin="start"', {"origin": "start"}),
('origin="end"', {"origin": "end"}),
('origin="center"', {"origin": "center"}),
('origin="edges"', {"origin": "edges"}),
('origin="random"', {"origin": "random"}),
(f"origin=({corner[0]:.2f}, {corner[1]:.2f})", {"origin": corner}),
("grid=(4, 7)", {"grid": (4, 7)}),
("easing=Easing.SMOOTH", {"easing": Easing.SMOOTH}),
]
waves, sizes = [], []
for panel, (label, options) in zip(panels, origins):
head, body = panel.split(Zones.rows(["1fr", "4fr"]))
scene.text(label, role="code").place(head, anchor="center")
spacing = min(body.width / 7, body.height / 4)
left, top = body.center[0] - spacing * 3, body.center[1] + spacing * 1.5
dots = [scene.geometry.dot(0.1).move_to(left + column * spacing, top - row * spacing)
for row in range(4) for column in range(7)]
waves.append(stagger(*[dot.animate.grow_from_center().duration(0.4) for dot in dots], total=1.0, **options))
sizes += [dot.animate.scale_to(size).duration(0.6)
for dot, size in zip(dots, distribute(dots, 1.6, 0.4, **options))]
scene.play(waves)
scene.play(sizes)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment early, midway and settled; the origins mid-wave and with
# their distributed sizes.
scene.snapshots(
snapshots,
[repeat_start + t for t in (0.4, 1.0, 1.6, 2.2, 2.8)]
+ [compose_start + t for t in (0.4, 0.8, 1.3, 2.4)]
+ [reshape_start + t for t in (0.4, 0.9, 1.5, 2.9)]
+ [origins_start + t for t in (0.45, 0.8, 2.3)],
)
else:
scene.render()Choreography
Labels and relative inserts drawn as a timeline from Composition.schedule(), and animations cued on beats and bars of a tempo under a launched sweep.

gaanim examples/animation/choreography.pyCode · 168 lines
"""Choreography: labels and relative inserts inside a composition, and cuts on a musical beat.
The first segment draws a composition as a timeline: each bar is one of its
animations, drawn while it plays, placed where ``Composition.schedule()``
puts it. The second sets a tempo and starts animations on beats and bars
while a launched sweep keeps running underneath.
"""
import os
from gaanim import Anchor, Easing, Scene, StrokeStyle, Zones, label, sequence
HOLD = 0.6
scene = Scene()
foreground = scene.canvas.color("foreground")
accent = scene.canvas.color("accent")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
def rows(count, label_share):
"""``count`` rows of a label column and a stage column, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=[label_share, "1fr"], column_gap="48px", padding="page_padding")
)
def axis(zone, ticks, scale, origin):
"""Number ``ticks`` along the middle of ``zone``, ``scale`` units apart from ``origin``."""
y = zone.center[1]
for index, tick in enumerate(ticks):
x = origin + index * scale
scene.geometry.rect(0.02, 0.2).fill(rule).move_to(x, y + 0.2)
scene.text(tick, role="code").fill(muted).move_to(x, y - 0.22)
# label() names an instant of a sequence; insert() adds an item at a label
# plus an offset, at the start ("<") or end (">") of the previous item, relative
# to the current end ("+=", "-="), or at a number of seconds.
scene.segment("labels")
grid = rows(7, "1fr")
stage = grid[1]
SCALE = stage.width / 2.4
start = stage.left + 0.15
def row_y(row):
"""The height of row ``row``'s stage."""
return grid[2 * row + 1].center[1]
def bar(row, seconds):
"""A thick line in row ``row`` as long as ``seconds``, and the animation that draws it."""
line = scene.geometry.line(0, row_y(row), seconds * SCALE, row_y(row))
line.stroke_style(StrokeStyle(accent, 0.3, cap="butt"))
return line, line.animate.create().duration(seconds).easing(Easing.LINEAR)
rows_of_bars = [0, 0, 1, 2, 3, 4, 5]
(a_line, a), (b_line, b), (c_line, c), (d_line, d), (e_line, e), (f_line, f), (g_line, g) = (
bar(row, seconds) for row, seconds in zip(rows_of_bars, [0.8, 0.6, 0.5, 0.7, 0.5, 0.4, 0.4])
)
intro = (
sequence(a, label("hit"), b)
.insert(c, at="hit+0.3")
.insert(d, at="<")
.insert(e, at=">-0.2")
.insert(f, at="-=0.4")
.insert(g, at=0.2)
)
# schedule() resolves every start and label without playing anything; each
# bar moves to where its animation starts.
plan = intro.schedule()
lines = [a_line, b_line, c_line, d_line, e_line, f_line, g_line]
for line, row, entry in zip(lines, rows_of_bars, plan.entries):
line.move_to(start + entry.start * SCALE, row_y(row), anchor=Anchor.LEFT)
for row, code in enumerate([
'sequence(a, label("hit"), b)',
'.insert(c, at="hit+0.3")',
'.insert(d, at="<")',
'.insert(e, at=">-0.2")',
'.insert(f, at="-=0.4")',
".insert(g, at=0.2)",
]):
scene.text(code, role="code").place(grid[2 * row], anchor="right")
hit = start + plan.labels["hit"] * SCALE
scene.geometry.line(hit, row_y(0) + 0.2, hit, grid[11].bottom).stroke(muted, 0.02)
scene.text('"hit"', role="code").move_to(hit, row_y(0) + 0.24, anchor=Anchor.BOTTOM)
axis(grid[13], ["0", "0.5", "1", "1.5", "2"], 0.5 * SCALE, start)
playhead = scene.geometry.line(start, grid[1].top, start, grid[11].bottom).stroke(foreground, 0.03)
# A marker names an instant of the whole timeline: the editor shows it on the
# seek bar and exports accept it in place of seconds.
scene.marker("intro")
scene.play([intro, playhead.animate.shift_by(plan.span * SCALE, 0).duration(plan.span).easing(Easing.LINEAR)])
scene.wait(HOLD)
# tempo() sets the beat grid, four beats to a bar unless beats_per_bar says
# otherwise; wait_until() moves the cursor to a beat or a bar, beats() turns
# beats into seconds, and launch() starts an animation without moving the
# cursor, so the sweep keeps running under everything after it.
scene.segment("beats")
grid = rows(6, "1.3fr")
BEATS = 8
stage = grid[1]
step = (stage.width - 0.3) / BEATS
origin = stage.left + 0.15
scene.tempo(120, offset=scene.cursor)
for row, code in enumerate([
"scene.wait_until(beat=n)",
"scene.wait_until(beat=n + 0.5)",
"scene.wait_until(bar=n)",
".duration(scene.beats(1.5))",
"scene.launch(sweep)",
"scene.tempo(120, offset=scene.cursor)",
]):
scene.text(code, role="code").place(grid[2 * row], anchor="right")
axis(grid[11], [str(beat) for beat in range(BEATS + 1)], step, origin)
def at(row, beat):
"""The point of ``row``'s stage at ``beat``."""
return origin + beat * step, grid[2 * row + 1].center[1]
def pop(row, beat, shape):
"""``shape`` at ``beat`` of ``row``, and a quarter-beat entrance."""
return shape.move_to(*at(row, beat)).animate.grow_from_center().duration(scene.beats(0.25))
# Cues are (beat, on_bar, animation); each starts on its beat, or on its bar.
cues = []
for beat in range(BEATS):
cues.append((beat, False, pop(0, beat, scene.geometry.dot(0.1))))
cues.append((beat + 0.5, False, pop(1, beat + 0.5, scene.geometry.dot(0.1))))
for beat in range(0, BEATS, 4):
cues.append((beat, True, pop(2, beat, scene.geometry.square(0.36))))
for beat in range(0, BEATS, 2):
(x, y), (x_end, _) = at(3, beat), at(3, beat + 1.5)
stroke = scene.geometry.line(x, y, x_end, y).stroke(accent, 0.12)
cues.append((beat, False, stroke.animate.create().duration(scene.beats(1.5)).easing(Easing.LINEAR)))
track_y = at(4, 0)[1]
scene.geometry.line(origin, track_y, origin + BEATS * step, track_y).stroke(rule, 0.02)
sweeper = scene.geometry.dot(0.14).move_to(origin, track_y)
sweep = sweeper.animate.shift_by(BEATS * step, 0).duration(scene.beats(BEATS)).easing(Easing.LINEAR)
beats_start = scene.cursor
scene.launch(sweep)
for beat, on_bar, animation in sorted(cues, key=lambda cue: cue[0]):
if on_bar:
scene.wait_until(bar=beat // 4)
else:
scene.wait_until(beat=beat)
scene.launch(animation)
scene.wait_until(bar=BEATS // 4)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The timeline as items start, overlap and end; the beats one bar in, a
# bar and a half in, and done.
scene.snapshots(
snapshots,
[scene.markers[0].time + t for t in (0.5, 1.0, 1.35, 1.75, 2.4)]
+ [beats_start + t for t in (1.2, 2.25, 3.4, 4.3)],
)
else:
scene.render()Motion paths
Travelling along circles, waves and arrows with move_along and its options, and straight moves bent into arcs with path_arc.

gaanim examples/animation/motion_paths.pyCode · 107 lines
"""Motion paths: travelling along any outline, and moving along an arc instead of a straight line.
``animate.move_along(path)`` follows the outline of any drawable, optionally
turning with it, and ``.path_arc(angle)`` bends a ``move_to`` or ``shift_by``
into a circular arc. Each panel is labelled with the call that moves its plane
or ball.
"""
import math
import os
from gaanim import Anchor, Easing, Scene, Zones
DURATION = 2.4
HOLD = 0.5
scene = Scene()
foreground = scene.canvas.color("foreground")
rule = scene.canvas.color("rule")
def panels(rows, columns, header):
"""Panels in a grid, each split into a label strip ``header`` units tall and a stage."""
grid = scene.layout.zones(Zones.grid(rows=rows, columns=columns, gap="48px", padding="page_padding"))
return [panel.split(Zones.rows([header, "1fr"], gap="12px")) for panel in grid]
def plane():
"""A paper plane pointing along +x."""
return scene.geometry.polygon([(0.32, 0.0), (-0.22, 0.18), (-0.1, 0.0), (-0.22, -0.18)])
def ring(zone):
"""A circle in the middle of ``zone``."""
return scene.geometry.circle(zone.height * 0.4).no_fill().stroke(rule, 0.02).move_to(*zone.center)
def wave(zone):
"""One period of a sine wave across ``zone``."""
(x, y), half, height = zone.center, zone.width * 0.42, zone.height * 0.3
points = [(x - half + 2 * half * i / 60, y + height * math.sin(math.tau * i / 60)) for i in range(61)]
return scene.geometry.polyline(points).no_fill().stroke(rule, 0.02)
def arrow(zone):
"""A curved arrow across ``zone``; move_along follows its axis from tail to tip."""
(x, y), half = zone.center, zone.width * 0.42
low = y - zone.height * 0.25
return scene.geometry.curved_arrow(x - half, low, x + half, low, -1.0).fill(rule).stroke(rule, 0.02)
# move_along samples the outline by arc length. orient=True turns the plane
# with the tangent, rotate_offset adds to that angle, and start/end pick the
# stretch travelled (start > end travels it backwards).
scene.segment("move_along")
travels = [
("move_along(ring)", ring, {}),
("move_along(ring, orient=True)", ring, {"orient": True}),
("move_along(ring, orient=True,\n rotate_offset=math.pi / 2)", ring,
{"orient": True, "rotate_offset": math.pi / 2}),
("move_along(wave, orient=True)", wave, {"orient": True}),
("move_along(wave, orient=True,\n start=0.8, end=0.2)", wave, {"orient": True, "start": 0.8, "end": 0.2}),
("move_along(arrow, orient=True)", arrow, {"orient": True}),
]
moves = []
for (head, stage), (label, path, options) in zip(panels(3, 2, 0.8), travels):
scene.text(label, role="code").move_to(*head.point("top_left"), anchor=Anchor.TOP_LEFT)
route = path(stage)
moves.append(plane().move_to(*stage.center).animate.move_along(route, **options).easing(Easing.LINEAR))
scene.play(moves, duration=DURATION)
scene.wait(HOLD)
# path_arc turns the straight move into an arc of `angle` radians:
# ball.animate.move_to(*end).path_arc(angle). Positive angles turn
# counterclockwise, so moving right they swing below the line.
scene.segment("path_arc")
arcs = [
("move_to(*end)", None),
("path_arc(math.pi / 3)", math.pi / 3),
("path_arc(-math.pi / 3)", -math.pi / 3),
("path_arc(math.pi / 2)", math.pi / 2),
("path_arc(math.pi)", math.pi),
("path_arc(-math.pi)", -math.pi),
]
moves = []
for (head, stage), (label, angle) in zip(panels(2, 3, 0.5), arcs):
scene.text(label, role="code").place(head, anchor="center")
(x, y), half = stage.center, min(stage.width, stage.height) * 0.42
start, end = (x - half, y), (x + half, y)
for point in (start, end):
scene.geometry.dot(0.06).fill(rule).move_to(*point)
ball = scene.geometry.circle(0.16).move_to(*start)
trail = scene.geometry.traced_path(ball).stroke(foreground, 0.02)
move = ball.animate.move_to(*end)
moves += [trail.animate.fade_in().duration(0.1), move if angle is None else move.path_arc(angle)]
scene.play(moves, duration=DURATION)
scene.wait(HOLD)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# A quarter, half and all the way along in each segment.
scene.snapshots(
snapshots,
[segment * (DURATION + HOLD) + t for segment in range(2) for t in (0.6, 1.2, 1.8, 2.8)],
)
else:
scene.render()Secondary motion
Follow-through with settle, trailing followers, squash and stretch, and exact light physics with throw and inertia.

gaanim examples/animation/secondary_motion.pyCode · 201 lines
"""Secondary motion: follow-through, trailing followers, squash and stretch, throws and flicks.
These add the motion that follows a main move: ``settle`` overshoots and swings
back, ``follow(delay=...)`` drags a tail behind a leader, ``squash_stretch``
deforms along the velocity, and ``throw`` and ``inertia`` move a drawable with
light, exact physics. Each row or panel is labelled with its code.
"""
import math
import os
from gaanim import Anchor, Easing, Scene, Zones
HOLD = 0.5
scene = Scene()
rule = scene.canvas.color("rule")
def rows(count):
"""``count`` rows of a label column and a stage column, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=["4fr", "3fr"], column_gap="48px", padding="page_padding")
)
def track(zone, ticks=()):
"""Draw a track across ``zone`` with ticks at the fractions ``ticks``; return its ends and height."""
(start, y), (end, _) = zone.point("left"), zone.point("right")
scene.geometry.line(start, y, end, y).stroke(rule, 0.02)
for fraction in ticks:
scene.geometry.rect(0.02, 0.24).fill(rule).move_to(start + (end - start) * fraction, y)
return start, end, y
def table(header, entries):
"""One row per entry under a ``header`` row: ``build(stage)`` returns the row's animation."""
grid = rows(len(entries) + 1)
scene.text(header, role="code").place(grid[0], anchor="right")
plays = []
for index, (label, build) in enumerate(entries, start=1):
scene.text(label, role="code").place(grid[2 * index], anchor="right")
plays.append(build(grid[2 * index + 1].inset((0, "48px"))))
scene.play(plays)
scene.wait(HOLD)
# settle adds a decaying swing past the target once the move ends:
# overshoot is seconds of swing per unit of final velocity, frequency the
# swings per second and decay how fast they fade.
def settling(**options):
"""A row whose dot moves to the middle tick and settles with ``options``."""
def build(zone):
start, end, y = track(zone, ticks=(0.5,))
target = (start + end) / 2
dot = scene.geometry.dot(0.12).move_to(start, y)
return dot.animate.move_to(target, y).duration(0.4).easing(Easing.SMOOTH).settle(**options)
return build
settle_start = scene.cursor
scene.segment("settle")
table("dot.animate.move_to(x, y).duration(0.4)", [
(".settle(overshoot=0)", settling(overshoot=0)),
(".settle()", settling()),
(".settle(overshoot=0.25)", settling(overshoot=0.25)),
(".settle(frequency=1.5)", settling(frequency=1.5)),
(".settle(frequency=6.0)", settling(frequency=6.0)),
(".settle(decay=3.0)", settling(decay=3.0)),
(".settle(decay=12.0)", settling(decay=12.0)),
])
# follow(leader, delay=...) places a drawable where the leader was that many
# seconds ago, so followers with growing delays drag a tail behind it.
def tail(spacing):
"""A row whose leader travels a wave with six followers ``spacing`` seconds apart."""
def build(zone):
(x, y), half, height = zone.center, zone.width / 2, zone.height * 0.3
points = [(x - half + 2 * half * i / 60, y + height * math.sin(1.5 * math.tau * i / 60)) for i in range(61)]
wave = scene.geometry.polyline(points).no_fill().stroke(rule, 0.02)
leader = scene.geometry.circle(0.16).move_to(*points[0])
followers = [scene.geometry.circle(0.13 - 0.015 * i).follow(leader, delay=spacing * (i + 1)) for i in range(6)]
# Followers stay hidden until an entrance shows them.
return [*(dot.animate.fade_in().duration(0.01) for dot in followers),
leader.animate.move_along(wave).duration(2.0).easing(Easing.SMOOTH)]
return build
follow_start = scene.cursor
scene.segment("follow")
grid = rows(3)
plays = []
for index, spacing in enumerate((0.03, 0.08, 0.16)):
scene.text(f"follow(leader, delay={spacing} * (i + 1))", role="code").place(grid[2 * index], anchor="right")
plays += tail(spacing)(grid[2 * index + 1])
scene.play(plays)
scene.wait(HOLD)
# squash_stretch stretches a drawable along its velocity by 1 + amount * speed,
# capped at max_ratio, and squashes it across to keep its area.
def squashing(*args, **options):
"""A row whose ball, with ``squash_stretch(*args, **options)``, crosses the track."""
def build(zone):
start, end, y = track(zone)
ball = scene.geometry.circle(0.2).move_to(start + 0.3, y).squash_stretch(*args, **options)
return ball.animate.move_to(end - 0.3, y).duration(1.0).easing(Easing.SMOOTH)
return build
squash_start = scene.cursor
scene.segment("squash")
table("ball.animate.move_to(x, y).duration(1.0)", [
("squash_stretch(0)", squashing(0)),
("squash_stretch(0.05)", squashing(0.05)),
("squash_stretch()", squashing()),
("squash_stretch(0.2)", squashing(0.2)),
("squash_stretch(0.2, max_ratio=1.2)", squashing(0.2, max_ratio=1.2)),
("squash_stretch(0.2, max_ratio=2.5)", squashing(0.2, max_ratio=2.5)),
])
# throw flies from where the drawable is under gravity and bounces on a floor,
# keeping `restitution` of its speed each time, until it rests; without a
# floor it lands back at its starting height.
throw_start = scene.cursor
scene.segment("throw")
panels = scene.layout.zones(Zones.grid(rows=3, columns=2, gap="48px", padding="page_padding"))
def launcher(index, label, from_top=False):
"""Label panel ``index``, draw its floor, and return a ball at its left and the floor's height."""
head, stage = panels[index].split(Zones.rows([0.8, "1fr"], gap="12px"))
scene.text(label, role="code").move_to(*head.point("top_left"), anchor=Anchor.TOP_LEFT)
floor = stage.bottom
scene.geometry.line(stage.left, floor, stage.right, floor).stroke(rule, 0.02)
ball = scene.geometry.circle(0.15).move_to(stage.left + 0.3, stage.top - 0.15 if from_top else floor + 0.15)
return ball, floor
plays = []
ball, floor = launcher(0, "throw((1.5, 4.8), floor=floor)")
plays.append(ball.animate.throw((1.5, 4.8), floor=floor))
ball, floor = launcher(1, "throw((1.5, 4.8), floor=floor,\n restitution=0.75)")
plays.append(ball.animate.throw((1.5, 4.8), floor=floor, restitution=0.75))
ball, floor = launcher(2, "throw((1.5, 4.8), floor=floor,\n restitution=0.3)")
plays.append(ball.animate.throw((1.5, 4.8), floor=floor, restitution=0.3))
ball, floor = launcher(3, "throw((1.5, 2.8), floor=floor,\n gravity=4.0)")
plays.append(ball.animate.throw((1.5, 2.8), floor=floor, gravity=4.0))
ball, _ = launcher(4, "throw((1.5, 4.8))")
plays.append(ball.animate.throw((1.5, 4.8)))
ball, floor = launcher(5, "throw(2.5, floor=floor)", from_top=True)
plays.append(ball.animate.throw(2.5, floor=floor))
scene.play(plays)
scene.wait(HOLD)
# inertia glides from where the drawable is and slows like a flick: it would
# rest velocity / friction away, or at the nearest snap point instead.
def gliding(velocity, notches=(), points=(), **options):
"""A row whose coin glides with ``velocity``, snapping to ``notches`` or ``points`` if given."""
def build(zone):
start, _, y = track(zone, ticks=notches)
stops = [(start + zone.width * fx, y + zone.height * fy) for fx, fy in points]
for point in stops:
scene.geometry.dot(0.06).fill(rule).move_to(*point)
snap = [start + zone.width * fraction for fraction in notches] or stops or None
coin = scene.geometry.circle(0.15).move_to(start, y)
return coin.animate.inertia(velocity, snap=snap, **options)
return build
inertia_start = scene.cursor
scene.segment("inertia")
NOTCHES = (0.0, 0.25, 0.5, 0.75, 1.0)
table("coin.animate.inertia(velocity, ...)", [
("inertia(6.0)", gliding(6.0)),
("inertia(6.0, friction=2.0)", gliding(6.0, friction=2.0)),
("inertia(6.0, snap=notches)", gliding(6.0, notches=NOTCHES)),
("inertia(6.0, friction=2.0, snap=notches)", gliding(6.0, notches=NOTCHES, friction=2.0)),
("inertia((6.0, 0.9))", gliding((6.0, 0.9))),
("inertia((6.0, 0.9), snap=points)", gliding((6.0, 0.9), points=((0.45, -0.25), (0.6, 0.3)))),
])
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# Each segment mid-move and settled.
scene.snapshots(
snapshots,
[settle_start + t for t in (0.46, 0.52, 0.62)] + [follow_start - 0.1]
+ [follow_start + t for t in (0.6, 1.3, 2.3)]
+ [squash_start + t for t in (0.3, 0.5, 1.3)]
+ [throw_start + t for t in (0.4, 1.2, 4.0)]
+ [inertia_start + t for t in (0.3, 0.8, 3.6)],
)
else:
scene.render()Procedural motion
Oscillators on every channel and waveform, wiggle, the other Updater presets, seeded randomness and smooth noise.

gaanim examples/animation/procedural_motion.pyCode · 212 lines
"""Procedural motion: updaters that oscillate, wiggle, orbit and spin, seeded randomness and noise.
An ``Updater`` adds motion that runs by itself, as a pure function of the
timeline, so seeks and exports match playback. ``scene.random`` draws
reproducible values for placing objects, and ``scene.noise`` is a smooth
signal over time to drive any reactive property. Every row or panel is
labelled with its code.
"""
import os
from gaanim import Easing, Scene, StrokeStyle, Updater, Zones, computed, stagger
RUN = 4.0
CROSSING = 4.0
scene = Scene()
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
def rows(count, label_share):
"""``count`` rows of a label column and a stage column, padded from the frame."""
return scene.layout.zones(
Zones.grid(rows=count, columns=[label_share, "2fr"], column_gap="48px", padding="page_padding")
)
# oscillate layers a periodic value between low and high on one channel: x, y
# and rotation add to the animated value, scale and opacity multiply it. Every
# waveform starts at low; phase shifts it by a fraction of a cycle.
oscillate_start = scene.cursor
scene.segment("oscillate")
WAVEFORMS = ["sine", "square", "triangle", "saw"]
CHANNELS = [
('"x", low=-0.4, high=0.4', ("x",), {"low": -0.4, "high": 0.4}),
('"y", high=0.3', ("y",), {"high": 0.3}),
('"y", high=0.3, phase=0.5', ("y",), {"high": 0.3, "phase": 0.5}),
('"rotation", high=0.8', ("rotation",), {"high": 0.8}),
('"scale", low=0.5', ("scale",), {"low": 0.5}),
('"opacity", low=0.15', ("opacity",), {"low": 0.15}),
]
grid = scene.layout.zones(
Zones.grid(rows=len(CHANNELS) + 1, columns=["5fr"] + ["2fr"] * 4, column_gap="48px", padding="page_padding")
)
scene.text("Updater.oscillate(...)", role="code").place(grid[0], anchor="right")
for column, waveform in enumerate(WAVEFORMS, start=1):
scene.text(f'"{waveform}"', role="code").place(grid[column], anchor="center")
for row, (label, args, options) in enumerate(CHANNELS, start=1):
scene.text(label, role="code").place(grid[5 * row], anchor="right")
for column, waveform in enumerate(WAVEFORMS, start=1):
cell = grid[5 * row + column]
scene.geometry.square(0.4).no_fill().stroke(rule, 0.02).move_to(*cell.center)
square = scene.geometry.square(0.4).move_to(*cell.center)
square.add_updater(Updater.oscillate(*args, waveform=waveform, frequency=0.5, **options))
scene.wait(RUN)
# wiggle layers seeded noise on position (scene units), rotation (radians) and
# scale (a fraction); frequency sets its pace and octaves its detail. Any
# amplitude can be a Parameter: animating it to 0 calms the jitter. The
# outline marks where each star rests.
wiggle_start = scene.cursor
scene.segment("wiggle")
shake = scene.viz.parameter(0.3)
WIGGLES = [
("Updater.wiggle()", {}),
("Updater.wiggle(position=0.3)", {"position": 0.3}),
("Updater.wiggle(position=0.3, seed=7)", {"position": 0.3, "seed": 7}),
("Updater.wiggle(position=0, rotation=0.5)", {"position": 0, "rotation": 0.5}),
("Updater.wiggle(position=0, scale=0.4)", {"position": 0, "scale": 0.4}),
("Updater.wiggle(position=0.3, frequency=0.5)", {"position": 0.3, "frequency": 0.5}),
("Updater.wiggle(position=0.3, frequency=6.0)", {"position": 0.3, "frequency": 6.0}),
("Updater.wiggle(position=0.3, octaves=8)", {"position": 0.3, "octaves": 8}),
("Updater.wiggle(position=shake)", {"position": shake}),
]
grid = rows(len(WIGGLES), "5fr")
for row, (label, options) in enumerate(WIGGLES):
scene.text(label, role="code").place(grid[2 * row], anchor="right")
center = grid[2 * row + 1].center
scene.geometry.star(5, 0.3, 0.13).no_fill().stroke(rule, 0.02).move_to(*center)
scene.geometry.star(5, 0.3, 0.13).move_to(*center).add_updater(Updater.wiggle(**options))
scene.wait(1.0)
scene.play(shake.animate.set(0).duration(2.0))
scene.wait(1.0)
# The other presets: an orbit, a vertical bob, a pulsing scale, a steady spin
# (a Parameter speed eases it up and down), a constant drift to the right,
# and dashes flowing along their path.
presets_start = scene.cursor
scene.segment("presets")
grid = rows(7, "3fr")
speed = scene.viz.parameter(0.0)
stages = [grid[2 * row + 1] for row in range(7)]
(ox, oy) = stages[0].center
scene.geometry.circle(0.35).no_fill().stroke(rule, 0.02).move_to(ox, oy)
(left, y), (right, _) = stages[6].point("left"), stages[6].point("right")
pipe = scene.geometry.line(left, y, right, y)
pipe.stroke_style(StrokeStyle(foreground, 0.08, cap="butt", dashes=[0.25, 0.15]))
presets = [
(f"Updater.orbit({ox:.2f}, {oy:.2f}, 0.35, 2.0)", scene.geometry.dot(0.1), Updater.orbit(ox, oy, 0.35, 2.0)),
("Updater.bob(0.25, 1.0)", scene.geometry.square(0.3).move_to(*stages[1].center), Updater.bob(0.25, 1.0)),
("Updater.pulse(0.6, 1.3, 1.0)", scene.geometry.square(0.3).move_to(*stages[2].center),
Updater.pulse(0.6, 1.3, 1.0)),
("Updater.rotate(2.0)", scene.geometry.rect(0.7, 0.12).move_to(*stages[3].center), Updater.rotate(2.0)),
("Updater.rotate(speed)", scene.geometry.rect(0.7, 0.12).move_to(*stages[4].center), Updater.rotate(speed)),
("Updater.advance_x(0.8)", scene.geometry.square(0.3).move_to(*stages[5].point("left")), Updater.advance_x(0.8)),
("Updater.dash_flow(0.8)", pipe, Updater.dash_flow(0.8)),
]
for row, (label, drawable, updater) in enumerate(presets):
scene.text(label, role="code").place(grid[2 * row], anchor="right")
drawable.add_updater(updater)
scene.play(speed.animate.set(8.0).duration(2.0).easing(Easing.SMOOTH))
scene.play(speed.animate.set(0.0).duration(2.0).easing(Easing.SMOOTH))
# scene.random(seed) draws the same values on every platform: uniform and
# gauss floats, integers, a choice from a list, and shuffles; a second stream
# with the same seed repeats the first panel exactly.
random_start = scene.cursor
scene.segment("random")
panels = scene.layout.zones(Zones.grid(rows=2, columns=3, gap="48px", padding="page_padding"))
palette = [scene.canvas.color(role) for role in ("accent", "success", "warning", "danger", "chart")]
def panel(index, label):
"""The stage of panel ``index``, under its ``label``."""
head, stage = panels[index].split(Zones.rows([0.5, "1fr"], gap="12px"))
scene.text(label, role="code").place(head, anchor="center")
return stage.inset(0.2)
def scatter(stream, stage):
"""Sixty dots at places drawn uniformly from ``stream`` inside ``stage``."""
dots = []
for _ in range(60):
x, y = stream.uniform(stage.left, stage.right), stream.uniform(stage.bottom, stage.top)
dots.append(scene.geometry.dot(0.07).move_to(x, y))
return dots
rng = scene.random(seed=42)
drawn = scatter(rng, panel(0, "rng.uniform(low, high)"))
stage = panel(1, "rng.gauss(mean, std)")
(cx, cy), spread = stage.center, stage.height / 6
drawn += [scene.geometry.dot(0.07).move_to(rng.gauss(cx, spread), rng.gauss(cy, spread)) for _ in range(60)]
stage = panel(2, "rng.integer(0, 6)")
counts = [0] * 6
for _ in range(30):
column = rng.integer(0, 6)
x = stage.left + (column + 0.5) * stage.width / 6
drawn.append(scene.geometry.square(0.2).move_to(x, stage.bottom + 0.15 + 0.26 * counts[column]))
counts[column] += 1
stage = panel(3, "rng.choice(palette)")
for row in range(4):
for column in range(8):
x, y = stage.left + (column + 0.5) * stage.width / 8, stage.top - (row + 0.5) * stage.height / 4
drawn.append(scene.geometry.circle(0.16).fill(rng.choice(palette)).move_to(x, y))
stage = panel(4, "rng.shuffle(heights)")
heights = [stage.height * (index + 1) / 8 for index in range(8)]
rng.shuffle(heights)
for column, height in enumerate(heights):
x = stage.left + (column + 0.5) * stage.width / 8
drawn.append(scene.geometry.rect(stage.width / 12, height).move_to(x, stage.bottom + height / 2))
drawn += scatter(scene.random(seed=42), panel(5, "scene.random(seed=42)"))
scene.play(stagger(*[item.animate.grow_from_center().duration(0.3) for item in drawn], total=1.0))
scene.wait(1.0)
def crossing(left, width):
"""An x that crosses ``width`` from ``left`` in CROSSING seconds from the noise segment's start."""
return computed(lambda t: left + width * min(max((t - noise_start) / CROSSING, 0), 1), inputs=[scene.time])
# scene.noise() is a smooth signal of timeline time within center ± amplitude.
# Each dot rides one noise signal up and down while it crosses its row, and
# its trail draws the signal.
noise_start = scene.cursor
scene.segment("noise")
NOISES = [
("scene.noise(amplitude=0.3)", {"amplitude": 0.3}),
("scene.noise(amplitude=0.3, seed=7)", {"amplitude": 0.3, "seed": 7}),
("scene.noise(amplitude=0.15)", {"amplitude": 0.15}),
("scene.noise(amplitude=0.3, frequency=3.0)", {"amplitude": 0.3, "frequency": 3.0}),
("scene.noise(amplitude=0.3, octaves=4)", {"amplitude": 0.3, "octaves": 4}),
("scene.noise(amplitude=0.15, center=0.2)", {"amplitude": 0.15, "center": 0.2}),
]
grid = rows(len(NOISES), "5fr")
trails = []
for row, (label, options) in enumerate(NOISES):
scene.text(label, role="code").place(grid[2 * row], anchor="right")
stage = grid[2 * row + 1]
(left, y), width = stage.point("left"), stage.width
scene.geometry.line(left, y, left + width, y).stroke(rule, 0.02)
signal = scene.noise(**options)
dot = scene.geometry.dot(0.07).move_to(crossing(left, width), computed(lambda v, y=y: y + v, inputs=[signal]))
trails.append(scene.geometry.traced_path(dot).stroke(muted, 0.02).animate.fade_in().duration(0.1))
scene.play(trails)
scene.wait(CROSSING)
if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
# The oscillators and wiggles at several phases, the wiggle calmed down,
# the presets spinning up, the random panels, and the noise drawn.
scene.snapshots(
snapshots,
[oscillate_start + t for t in (0.4, 1.1, 1.6, 2.3)]
+ [wiggle_start + t for t in (0.5, 1.5, 3.6)]
+ [presets_start + t for t in (0.5, 1.8, 3.6)]
+ [random_start + t for t in (0.6, 1.8)]
+ [noise_start + t for t in (1.5, 4.0)],
)
else:
scene.render()