First scene

A first scene: objects on a stage, and play, wait and immediate changes moving the timeline cursor, drawn as a timeline.

First scene
View on GitHubgaanim examples/basics/first_scene.py
Code · 98 lines
"""A first scene: objects on a stage, and play, wait and cuts moving the timeline cursor.

The top half is the stage. The bottom half charts the script that animates it,
one row per line, labelled with that line: its bar spans the seconds the line
takes on the timeline, and the numbers under the chart are the cursor after
each line. ``play`` starts its animations together at the cursor and moves the
cursor to their end, ``wait`` only moves it, and a setter called once the
timeline has started, like ``box.fill(success)``, is an instant cut.
"""

import os

from gaanim import Easing, Scene, StrokeStyle, Zones

scene = Scene()
accent = scene.canvas.color("accent")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
success = scene.canvas.color("success")

page = scene.layout.zones(Zones.rows(["1fr", "1.35fr"], gap="40px", padding="page_padding"))
stage, chart = page[0], page[1]

# Factories on the scene make objects, each at the origin until it is placed.
# Building them is free: they show up when their entrance animation starts.
ball = scene.geometry.circle(1.2)
box = scene.geometry.square(2.2)
word = scene.text("Hello", size=1.3)
stage.arrange(ball, box, word, gap="200px")

# The script below, as (line, seconds it takes on the timeline).
SCRIPT = [
    ("scene.play(ball.animate.create())", 1.0),
    ("scene.play([box.animate.fade_in(),\n            word.animate.write()])", 1.0),
    ("scene.wait(0.5)", 0.5),
    ("box.fill(success)", 0.0),
    ("scene.play(box.animate.rotate_by(0.8))", 1.0),
    ("scene.play(ball.animate.scale_to(0.6))", 1.0),
    ("scene.wait(1)", 1.0),
]
TOTAL = sum(seconds for _, seconds in SCRIPT)


def draw_chart():
    """Chart SCRIPT under the stage and sweep a playhead across it in real time."""
    tracks = ["2fr" if "\n" in line else "1fr" for line, _ in SCRIPT] + ["1fr"]
    grid = chart.split(Zones.grid(rows=tracks, columns=[8.3, "1fr"], column_gap="32px"))
    lanes = [grid[2 * index + 1] for index in range(len(tracks))]
    left, right = lanes[0].left + 0.1, lanes[0].right - 0.3
    top, bottom = lanes[0].top, lanes[-1].top

    def x_at(t):
        return left + (right - left) * t / TOTAL

    start = 0.0
    times = [start]
    reveals = []
    for index, (line, seconds) in enumerate(SCRIPT):
        scene.text(line, role="code").place(grid[2 * index], anchor="left")
        y = lanes[index].center[1]
        if seconds == 0:
            # A cut takes no time: a tick at the cursor where it happens.
            mark = scene.geometry.rect(0.1, 0.42).fill(success).move_to(x_at(start), y)
            reveals.append(mark.animate.fade_in().duration(0.1).delay(start))
        else:
            # A play fills its bar; a wait leaves the cursor's path dim.
            color = rule if line.startswith("scene.wait") else accent
            bar = scene.geometry.line(x_at(start), y, x_at(start + seconds), y)
            bar.stroke_style(StrokeStyle(color, 0.3, cap="butt")).trim(end=0)
            reveals.append(bar.animate.trim(end=1).duration(seconds).delay(start).easing(Easing.LINEAR))
        start += seconds
        times.append(start)
    for t in sorted(set(times)):
        scene.geometry.line(x_at(t), top, x_at(t), bottom).stroke(rule, 0.01)
        scene.text(f"{t:g}", role="code").fill(muted).move_to(x_at(t), lanes[-1].center[1])
    playhead = scene.geometry.line(x_at(0), top, x_at(0), bottom).stroke(foreground, 0.03)
    reveals.append(playhead.animate.shift_by(x_at(TOTAL) - x_at(0), 0).duration(TOTAL).easing(Easing.LINEAR))
    # launch() starts the chart at the cursor without moving it.
    scene.launch(reveals)


draw_chart()

scene.play(ball.animate.create())
scene.play([box.animate.fade_in(), word.animate.write()])
scene.wait(0.5)
box.fill(success)
scene.play(box.animate.rotate_by(0.8))
scene.play(ball.animate.scale_to(0.6))
scene.wait(1)

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # During each line of the script, and settled at the end.
    scene.snapshots(snapshots, [0.5, 1.5, 2.25, 3.0, 4.0, 5.4])
else:
    scene.render()

Scene tour

A tour of the scene: the factories that make shapes, text, equations, code, layouts, plots, SVGs and readouts, and the animations that change them.

Scene tour
View on GitHubgaanim examples/basics/scene_tour.py
Code · 133 lines
"""A tour of the scene: the factories that make objects and the animations that change them.

Every object comes from a factory on the scene (``geometry``, ``text``,
``layout``, ``viz``, ``media``), and every change is an animation from the
object's ``animate`` proxy, played on the timeline. Each row is one building
block, labelled with the code that makes or changes it; the other topics cover
each of them in full.
"""

import math
import os

from gaanim import Axis, Scene, Zones, parallel

DURATION = 1.4
HOLD = 0.8

scene = Scene()
panel = scene.canvas.color("panel")
rule = scene.canvas.color("rule")
success = scene.canvas.color("success")


def table(rows, tracks=None):
    """One row per (label, build) pair: the label, then the stage where ``build(stage)``
    draws the row and returns its animation."""
    grid = scene.layout.zones(
        Zones.grid(rows=tracks or len(rows), columns=[8.8, "1fr"], gap="24px", column_gap="64px",
                   padding="page_padding")
    )
    moves = []
    for index, (label, build) in enumerate(rows):
        scene.text(label, role="code").place(grid[2 * index], anchor="right")
        moves.append(build(grid[2 * index + 1]))
    scene.play(moves, duration=DURATION)
    scene.wait(HOLD)


SOURCE = 'def greet(name):\n    print("hi", name)'


def code_panel(stage):
    panel = scene.text.code(SOURCE, language="py", width=5.4, height=1.8, font_size=0.36)
    return panel.move_to(*stage.center).animate.fade_in()


# Drawing: shapes, text, equations and code, each with the entrance that suits it.
scene.segment("draw")
table(
    [
        ("scene.geometry.star(5, 0.75, 0.32)",
         lambda stage: scene.geometry.star(5, 0.75, 0.32).move_to(*stage.center).animate.create()),
        ('scene.text("Hello, Gaanim", size=0.75)',
         lambda stage: scene.text("Hello, Gaanim", size=0.75).move_to(*stage.center).animate.write()),
        ('scene.text.equation("E = m c^2", size=0.8)',
         lambda stage: scene.text.equation("E = m c^2", size=0.8).move_to(*stage.center).animate.write()),
        ('scene.text.code(SOURCE, language="py",\n'
         "                width=5.4, height=1.8,\n"
         "                font_size=0.36)", code_panel),
    ],
    tracks=["1fr", "1fr", "1fr", "2fr"],
)


def boxes(stage):
    words = ("one", "two", "three")
    boxes = [scene.layout.box(word, padding=("12px", "28px"), radius="12px", background=panel, font_size="64px") for word in words]
    row = scene.layout.row(*boxes, gap="32px")
    return row.place(stage).animate.fade_in()


def sine(stage):
    plane = scene.viz.cartesian_2d(Axis.linear(-3, 3), Axis.linear(-1.2, 1.2), width=4.6, height=1.5,
                                   numbers=False)
    plane.move_to(*stage.center)
    return parallel(plane.animate.create(), plane.plot(math.sin).animate.create())


def character(stage):
    return scene.media.svg("assets/character.svg").place(stage, fit="contain").animate.fade_in()


def clock(stage):
    readout = scene.viz.readout(scene.time, label="t", format=".2f", unit="s", font_size=0.6)
    return readout.place(stage).animate.fade_in()


# Building: boxes that lay themselves out, plots on axes, imported artwork and
# numbers that follow a value (here the playhead's time).
scene.segment("build")
table(
    [
        ('scene.layout.row(*boxes, gap="32px")', boxes),
        ("plane = scene.viz.cartesian_2d(x, y)\nplane.plot(math.sin)", sine),
        ('scene.media.svg("assets/character.svg")', character),
        ('scene.viz.readout(scene.time, label="t")', clock),
    ],
    tracks=["1fr", "1.3fr", "1.5fr", "1fr"],
)


def morph(stage):
    energy = scene.text.equation("E = m c^2", size=0.8).move_to(*stage.center)
    momentum = scene.text.equation("p = m v", size=0.8).move_to(*stage.center)
    return energy.animate.transform_to(momentum)


def travel(stage):
    (x0, y), (x1, _) = stage.point("left"), stage.point("right")
    path = scene.geometry.bezier((x0 + 0.3, y - 0.5), [(x0 + 2, y + 1.6), (x1 - 2, y - 1.6)], (x1 - 0.3, y + 0.5))
    path.no_fill().stroke(rule, 0.03)
    dot = scene.geometry.dot(0.16).move_to(x0 + 0.3, y - 0.5)
    return dot.animate.move_along(path)


# Changing: morphing one object into another, drawing attention, moving along a
# path and animating a property.
scene.segment("change")
table([
    ("energy.animate.transform_to(momentum)", morph),
    ("star.animate.indicate()",
     lambda stage: scene.geometry.star(5, 0.8, 0.34).move_to(*stage.center).animate.indicate()),
    ("dot.animate.move_along(path)", travel),
    ("circle.animate.fill(success)",
     lambda stage: scene.geometry.circle(0.75).move_to(*stage.center).animate.fill(success)),
])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # Mid-animation and settled, in each of the three segments.
    scene.snapshots(snapshots, [(DURATION + HOLD) * index + t for index in range(3) for t in (0.6, 1.9)])
else:
    scene.render()

Groups

Groups that move, turn and animate as one, nested arms that turn about their joints, and groups lined up or repeated.

Groups
View on GitHubgaanim examples/basics/groups.py
Code · 169 lines
"""Groups: several objects that move, turn and animate as one, nested and lined up.

``scene.geometry.group`` gathers drawables into one: the group transforms about
the center of its box, each member keeps its own handle, and a group can hold
other groups, each turning about its own pivot. Each segment covers one part,
and each label is the code that animates or builds its row.
"""

import math
import os

from gaanim import Scene, Zones, parallel

DURATION = 1.4
HOLD = 0.6

scene = Scene()
foreground = scene.canvas.color("foreground")
rule = scene.canvas.color("rule")
panel = scene.canvas.color("panel")
danger = scene.canvas.color("danger")
success = scene.canvas.color("success")


def house(size=1.3):
    """A house grouped from a wall, a roof and a door; returns (house, roof, door)."""
    wall = scene.geometry.rect(size, 0.7 * size).move_to(0, -0.225 * size)
    roof = scene.geometry.polygon([(-0.6 * size, 0.125 * size), (0.6 * size, 0.125 * size), (0, 0.575 * size)])
    roof.fill(danger)
    door = scene.geometry.rect(0.24 * size, 0.4 * size).fill(panel).move_to(0.15 * size, -0.375 * size)
    return scene.geometry.group([wall, roof, door]), roof, door


def table(rows, tracks=None, label_width=8.0):
    """One row per (label, build) pair: the label, then the stage where ``build(stage)``
    draws the row and returns its animation."""
    grid = scene.layout.zones(
        Zones.grid(rows=tracks or len(rows), columns=[label_width, "1fr"], gap="24px", column_gap="64px",
                   padding="page_padding")
    )
    moves = []
    for index, (label, build) in enumerate(rows):
        scene.text(label, role="code").place(grid[2 * index], anchor="right")
        moves.append(build(grid[2 * index + 1]))
    scene.play(moves, duration=DURATION)
    scene.wait(HOLD)


def centered(zone, size=1.3):
    """A house in the middle of ``zone``; returns (house, roof, door)."""
    parts = house(size)
    parts[0].move_to(*zone.center)
    return parts


# A group moves, turns, scales and shears as one, about the center of its box.
scene.segment("as_one")
table([
    ("house.animate.shift_by(3, 0)",
     lambda stage: house()[0].move_to(stage.left + 1.3, stage.center[1]).animate.shift_by(3, 0)),
    ("house.animate.rotate_by(math.pi / 4)", lambda stage: centered(stage)[0].animate.rotate_by(math.pi / 4)),
    ("house.animate.scale_to(1.3)", lambda stage: centered(stage)[0].animate.scale_to(1.3)),
    ("house.animate.skew_to(0.5, 0)", lambda stage: centered(stage)[0].animate.skew_to(0.5, 0)),
])

# Members keep their handles: animate the group, or one member inside it.
# A group's create() draws its members one after another; lag_ratio sets how
# much they overlap, from 0 (all together) to 1.
scene.segment("members")
table([
    ("house.animate.create()", lambda stage: centered(stage, 1.0)[0].animate.create()),
    ("house.animate.create().lag_ratio(0)", lambda stage: centered(stage, 1.0)[0].animate.create().lag_ratio(0)),
    ("house.animate.opacity(0.25)", lambda stage: centered(stage, 1.0)[0].animate.opacity(0.25)),
    ("roof.animate.shift_by(0, 0.4)", lambda stage: centered(stage, 1.0)[1].animate.shift_by(0, 0.4)),
    ("door.animate.fill(success)", lambda stage: centered(stage, 1.0)[2].animate.fill(success)),
])


def robot_arm(stage):
    """An arm holding a forearm holding a hand, each group turning about its own joint.

    Returns (arm, forearm, hand). The base under the shoulder is not grouped.
    """
    shoulder = (stage.left + 0.6, stage.bottom + 0.4)
    elbow = (shoulder[0] + 1.9, shoulder[1])
    wrist = (elbow[0] + 1.5, elbow[1])
    scene.geometry.rect(0.9, 0.3).fill(rule).move_to(shoulder[0], shoulder[1] - 0.25)
    upper = scene.geometry.rounded_rect(1.9, 0.3, 0.15).move_to(shoulder[0] + 0.95, shoulder[1])
    lower = scene.geometry.rounded_rect(1.5, 0.26, 0.13).move_to(elbow[0] + 0.75, elbow[1])
    wx, wy = wrist
    claws = [
        scene.geometry.polyline([(wx, wy), (wx + 0.3, wy + sign * 0.28), (wx + 0.6, wy + sign * 0.12)])
        .stroke(foreground, 0.1)
        for sign in (1, -1)
    ]
    joints = [scene.geometry.circle(0.17).fill(foreground).move_to(*point) for point in (shoulder, elbow, wrist)]
    hand = scene.geometry.group([*claws, joints[2]]).with_pivot(*wrist)
    forearm = scene.geometry.group([lower, joints[1], hand]).with_pivot(*elbow)
    arm = scene.geometry.group([upper, joints[0], forearm]).with_pivot(*shoulder)
    return arm, forearm, hand


def all_three(stage):
    arm, forearm, hand = robot_arm(stage)
    return parallel(arm.animate.rotate_by(0.2), forearm.animate.rotate_by(0.5), hand.animate.rotate_by(0.8))


# Groups nest: turning the arm carries the forearm and the hand along, while
# each inner group still turns about its own joint, set with with_pivot.
scene.segment("nesting")
table(
    [
        ("arm.animate.rotate_by(0.3)", lambda stage: robot_arm(stage)[0].animate.rotate_by(0.3)),
        ("forearm.animate.rotate_by(0.8)", lambda stage: robot_arm(stage)[1].animate.rotate_by(0.8)),
        ("hand.animate.rotate_by(1.2)", lambda stage: robot_arm(stage)[2].animate.rotate_by(1.2)),
        ("parallel(arm.animate.rotate_by(0.2),\n"
         "         forearm.animate.rotate_by(0.5),\n"
         "         hand.animate.rotate_by(0.8))", all_three),
    ],
    tracks=["2fr", "2fr", "2fr", "3fr"],
    label_width=8.8,
)


def lines(rows):
    """Each (label, build) pair gets its label above a wide stage, where
    ``build(zone)`` lines up houses and returns them as one group."""
    tracks = []
    for _ in rows:
        tracks += [0.45, "1fr"]
    grid = scene.layout.zones(Zones.rows(tracks, gap="12px", padding="page_padding"))
    groups = []
    for index, (label, build) in enumerate(rows):
        scene.text(label, role="code").place(grid[2 * index], anchor="left")
        groups.append(build(grid[2 * index + 1]))
    scene.play([group.animate.create().lag_ratio(0.5) for group in groups], duration=DURATION)
    scene.wait(HOLD)


def arranged(**options):
    """Five houses lined up once by ``zone.arrange``, then grouped."""
    def build(zone):
        houses = [house(1.0)[0] for _ in range(5)]
        zone.arrange(*houses, **options)
        return scene.geometry.group(houses)
    return build


def repeated(zone):
    first = house(1.0)[0].move_to(zone.left + 0.7, zone.center[1])
    return scene.geometry.repeat(first, 6, offset=(1.6, 0))


# Lining members up: zone.arrange places free objects once (Layout covers
# rows, grids and zones), and repeat() builds a group of shifted copies.
scene.segment("arrange")
lines([
    ("zone.arrange(*houses)", arranged()),
    ('zone.arrange(*houses, gap="120px")', arranged(gap="120px")),
    ('zone.arrange(*houses, justify="between")', arranged(justify="between")),
    ("scene.geometry.repeat(house, 6, offset=(1.6, 0))", repeated),
])

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    # Mid-animation and settled, in each of the four segments.
    scene.snapshots(snapshots, [(DURATION + HOLD) * index + t for index in range(4) for t in (0.6, 1.8)])
else:
    scene.render()

Segments

Segments as chapters of a scene: notes, reuse, persistent objects, release, stops and markers.

Segments
View on GitHubgaanim examples/basics/segments.py
Code · 143 lines
"""Segments: the chapters of a scene, what carries over between them, and the stops and markers inside them.

``scene.segment`` starts a new page: what earlier segments drew leaves the
frame, unless ``reuse`` adopts it into the new segment or ``persist`` keeps it
in every segment that follows, like the clock at the bottom. ``stop`` pauses a
presentation (a ``loop`` keeps it moving meanwhile) and ``marker`` names an
instant. The text at the top of each page is the code that builds it, and the
last page draws the scene's own map from ``scene.stops`` and ``scene.markers``.
"""

import math
import os

from gaanim import Easing, Scene, Transition, Zones

DRAW = 1.2
HOLD = 0.6
FADE = 0.8

scene = Scene()
accent = scene.canvas.color("accent")
chart = scene.canvas.color("chart")
foreground = scene.canvas.color("foreground")
muted = scene.canvas.color("muted")
rule = scene.canvas.color("rule")
success = scene.canvas.color("success")

# Where each segment starts on the timeline, read from scene.cursor.
starts = {}


def page(*calls):
    """Write ``calls`` at the top of a new page; returns its stage and footer zones."""
    zones = scene.layout.zones(Zones.rows([1.0, "1fr", 0.6], gap="32px", padding="page_padding"))
    scene.text("\n".join(calls), role="code").place(zones[0], anchor="top_left")
    return zones[1], zones[2]


# A segment is a page of the scene; notes are what Presenter View shows for it.
scene.segment("shapes", notes="Three shapes, and a clock that stays.")
starts["shapes"] = scene.cursor
stage, footer = page('scene.segment("shapes", notes="Three shapes, and a clock that stays.")')
circle = scene.geometry.circle(1.3)
square = scene.geometry.square(2.3)
triangle = scene.geometry.regular_polygon(3, 1.5)
stage.arrange(circle, square, triangle, gap="240px")

# persist() keeps the clock and its caption in every later segment, outside
# the transitions between them.
caption = scene.text("scene.persist(clock, caption)", role="code").fill(muted)
clock = scene.viz.readout(scene.time, label="t", format=".1f", unit="s", font_size=0.4)
footer.arrange(caption, clock, gap="64px", justify="start")
scene.play([shape.animate.create() for shape in (circle, square, triangle)]
           + [caption.animate.fade_in(), clock.animate.fade_in()], duration=DRAW)
scene.persist(clock, caption)
scene.marker("drawn")
scene.wait(HOLD)
scene.stop("shapes")

# The square and the triangle leave with the cross fade; reuse() adopts the
# circle into the new segment, which then moves it.
scene.segment("reuse", Transition.cross_fade(FADE))
starts["reuse"] = scene.cursor
stage, _ = page(
    'scene.segment("reuse", Transition.cross_fade(0.8))',
    "scene.reuse(circle)",
    'scene.stop("orbit", loop=orbit.animate.rotate_by(math.tau / 6))',
)
scene.reuse(circle)
x, y = stage.center
orbit = scene.geometry.group([
    scene.geometry.dot(0.2).fill(chart).move_to(x + 2.4 * math.cos(k * math.tau / 6), y + 2.4 * math.sin(k * math.tau / 6))
    for k in range(6)
])
scene.wait(FADE)
scene.play([circle.animate.move_to(x, y), orbit.animate.create()], duration=DRAW)
scene.marker("centered")
# A stop's loop repeats while a presentation rests there; exports play it once.
# A sixth of a turn ends where it starts, so the repeat is seamless.
scene.stop("orbit", loop=orbit.animate.rotate_by(math.tau / 6).duration(1.5).easing(Easing.LINEAR))
scene.wait(HOLD)

scene.segment("map", Transition.cross_fade(FADE))
starts["map"] = scene.cursor
stage, _ = page(
    'scene.segment("map", Transition.cross_fade(0.8))',
    "scene.release(clock, caption)",
    "scene.fade_out_all(0.8)",
)
# The page waits out the cross fade, draws the map, holds it and fades out.
MAP = FADE + DRAW + HOLD + FADE
end = scene.cursor + MAP


def x_at(t):
    return stage.left + 0.2 + (stage.width - 0.4) * t / end


# The map: one bar per segment, the stops above it and the markers below.
mid = stage.center[1]
pieces = []
names = list(starts)
background = scene.canvas.color("background")
for index, name in enumerate(names):
    start = starts[name]
    stop = starts[names[index + 1]] if index + 1 < len(names) else end
    center = (x_at(start) + x_at(stop)) / 2
    bar = scene.geometry.rect(x_at(stop) - x_at(start) - 0.06, 0.9).fill(accent if index % 2 == 0 else chart)
    label = scene.text(f'"{name}"', role="code").fill(background).move_to(center, mid)
    pieces += [bar.move_to(center, mid), label]
for item in scene.stops:
    x = x_at(item.time)
    if item.loop_duration:
        loop = scene.geometry.line(x, mid + 0.8, x_at(item.time + item.loop_duration), mid + 0.8)
        pieces.append(loop.stroke(success, 0.14))
    tick = scene.geometry.line(x, mid + 0.45, x, mid + 1.45).stroke(foreground, 0.04)
    name = scene.text(f'stop("{item.name}")', role="code").move_to(x, mid + 1.8)
    pieces += [tick, name]
for item in scene.markers:
    x = x_at(item.time)
    flag = scene.geometry.polygon([(x, mid - 0.5), (x - 0.2, mid - 0.85), (x + 0.2, mid - 0.85)]).fill(success)
    name = scene.text(f'marker("{item.name}")', role="code").move_to(x, mid - 1.2)
    pieces += [flag, name]
playhead = scene.geometry.line(x_at(scene.cursor), mid - 0.6, x_at(scene.cursor), mid + 0.6).stroke(foreground, 0.06)

scene.launch(playhead.animate.move_to(x_at(end), mid).duration(MAP).easing(Easing.LINEAR))
scene.wait(FADE)
scene.play([piece.animate.fade_in() for piece in pieces], duration=DRAW)
scene.wait(HOLD)
# release() hands the clock back to this segment, so fade_out_all() takes it too.
scene.release(clock, caption)
scene.fade_out_all(FADE)

# Drawing, cross fading and settled on each page, and the final fade.
SEEKS = [0.6, DRAW + 0.3]
SEEKS += [starts["reuse"] + t for t in (0.4, FADE + 0.6, FADE + DRAW + 0.7)]
SEEKS += [starts["map"] + t for t in (0.4, FADE + DRAW + 0.3, MAP - 0.3)]

if snapshots := os.environ.get("GAANIM_SNAPSHOTS"):
    scene.snapshots(snapshots, SEEKS)
else:
    scene.render()