Camera moves

Pan, zoom and rotate the camera, frame objects with margins, and follow a moving target.

Camera moves
View on GitHubgaanim examples/camera/camera_moves.py
Code · 148 lines
"""Camera moves: panning, zooming, rotating, framing objects and following them.

The camera looks at a map of tiles labelled with their coordinates, so every
pan lands on a tile you can read. A strip pinned to the screen with ``hud()``
names the call that moves the camera; each segment covers one family of moves.
"""

import os

from gaanim import Easing, Scene, Zones

MOVE = 1.0
LEG = 1.6
HOLD = 0.4
SPACING = 3.0
SEEKS = []

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


def world(columns=range(-6, 7), rows=range(-4, 5)):
    """Tiles every ``SPACING`` units, each labelled with the coordinates of its center."""
    for column in columns:
        for row in rows:
            x, y = column * SPACING, row * SPACING
            scene.geometry.rounded_rect(2.8, 2.8, 0.16).no_fill().stroke(rule, 0.02).move_to(x, y)
            scene.text(f"({x:g}, {y:g})", role="code").fill(muted).scale_to(1.3).move_to(x, y - 0.95)


class Captions:
    """A strip pinned to the bottom of the screen whose caption names each shot."""

    def __init__(self):
        strip = scene.layout.zones(Zones.rows(["1fr", "80px"]))[1]
        scene.geometry.rect(strip.width, strip.height).fill(panel).no_stroke().hud().move_to(*strip.center)
        self.zone = strip.inset((0, "page_padding"))
        self.current = None

    def shot(self, label, *moves, duration=MOVE, hold=HOLD, midway=False, end=True, also=()):
        """Show ``label`` and play ``moves``, each lasting ``duration``, with ``also`` as they are.

        Records for the snapshots the middle of the shot when ``midway`` is
        set, and its end unless ``end`` is off.
        """
        caption = scene.text(label, role="code").hud().place(self.zone, anchor="left").hidden()
        swap = [caption.animate.fade_in().duration(0.2)]
        if self.current is not None:
            swap.append(self.current.animate.fade_out().duration(0.15))
        self.current = caption
        if midway:
            SEEKS.append(scene.cursor + duration / 2)
        scene.play(swap + [move.duration(duration) for move in moves] + list(also))
        scene.wait(hold)
        if end:
            SEEKS.append(scene.cursor - min(hold / 2, 0.1) - 0.05)


# Pan centers a point or a drawable, zoom scales the view about its center
# (values above one zoom in; the default interpolation is exponential, so a
# large zoom reads at a constant pace) and rotate turns it, in radians.
# Without .animate the same calls cut to the new pose at the cursor. The
# camera keeps its pose from one segment to the next, so later segments
# start with a cut back to the default pose.
scene.segment("pan_zoom_rotate")
world()
star = scene.geometry.star(5, 0.8, 0.36).fill(accent).move_to(-9, 3.2)
captions = Captions()
captions.shot("scene.camera.animate.pan_to(6, 3)", scene.camera.animate.pan_to(6, 3))
captions.shot("scene.camera.animate.pan_to(star)", scene.camera.animate.pan_to(star))
captions.shot("scene.camera.animate.zoom_to(2.5)", scene.camera.animate.zoom_to(2.5))
captions.shot(
    'scene.camera.animate.zoom_to(0.7, interpolation="linear")',
    scene.camera.animate.zoom_to(0.7, interpolation="linear"),
)
captions.shot("scene.camera.animate.rotate_to(0.3)", scene.camera.animate.rotate_to(0.3))
scene.camera.pan_to(-6, -3).zoom_to(1.5).rotate_to(0)
captions.shot("scene.camera.pan_to(-6, -3).zoom_to(1.5).rotate_to(0)")
captions.shot("scene.camera.animate.reset()", scene.camera.animate.reset())

# frame_to pans and zooms so the targets fit the view. The margin is half a
# unit by default, one value, (vertical, horizontal) or four sides in CSS
# order; dynamic=True keeps framing targets that move during the shot, and
# the camera keeps the last framing when it ends.
scene.segment("frame_to")
scene.camera.reset()
world()
circle = scene.geometry.circle(0.8).fill(success).move_to(-6, 3.2)
square = scene.geometry.square(1.4).fill(danger).move_to(6, -2.8)
captions = Captions()
captions.shot("scene.camera.animate.frame_to(circle)", scene.camera.animate.frame_to(circle))
captions.shot("frame_to(circle, margin=3)", scene.camera.animate.frame_to(circle, margin=3))
captions.shot("frame_to(square, margin=(0.5, 4))", scene.camera.animate.frame_to(square, margin=(0.5, 4)))
captions.shot(
    "frame_to(square, margin=(0.3, 0.3, 3, 0.3))",
    scene.camera.animate.frame_to(square, margin=(0.3, 0.3, 3, 0.3)),
)
captions.shot(
    'frame_to(circle, margin=1, interpolation="linear")',
    scene.camera.animate.frame_to(circle, margin=1, interpolation="linear"),
)
captions.shot("frame_to([circle, square], margin=1)", scene.camera.animate.frame_to([circle, square], margin=1))
captions.shot(
    "frame_to([circle, square], margin=1, dynamic=True)",
    scene.camera.animate.frame_to([circle, square], margin=1, dynamic=True),
    square.animate.move_to(15, -8.8),
    duration=1.6,
)

# follow keeps a target in view while it plays and leaves the camera where it
# took it: lag eases the camera onto the target over about that many seconds,
# offset shifts the view from the target, and offset_space="local" turns the
# offset with it. Each shot lasts through the hold, so the camera settles.
scene.segment("follow")
scene.camera.reset()
world()
route = scene.geometry.circle(9).no_fill().stroke(accent, 0.04)
rover = scene.geometry.polygon([(0.9, 0.0), (-0.7, 0.56), (-0.35, 0.0), (-0.7, -0.56)]).fill(accent).move_to(9, 0)
captions = Captions()


def follow(label, move, quarter):
    """Follow the rover with ``move`` while it drives a quarter of the route, and through the hold."""
    drive = rover.animate.move_along(route, orient=True, start=quarter / 4, end=(quarter + 1) / 4)
    drive = drive.duration(LEG).easing(Easing.LINEAR)
    captions.shot(label, move, duration=LEG + HOLD, hold=0, midway=True, end=False, also=[drive])


follow("scene.camera.animate.follow(rover)", scene.camera.animate.follow(rover), 0)
follow("follow(rover, lag=1)", scene.camera.animate.follow(rover, lag=1), 1)
follow("follow(rover, offset=(4, 0))", scene.camera.animate.follow(rover, offset=(4, 0)), 2)
follow(
    'follow(rover, offset=(4, 0), offset_space="local")',
    scene.camera.animate.follow(rover, offset=(4, 0), offset_space="local"),
    3,
)
captions.shot("scene.camera.animate.reset()", scene.camera.animate.reset())

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

Camera states

Reusable camera poses: state_2d, capture, to, and named save and restore.

Camera states
View on GitHubgaanim examples/camera/camera_states.py
Code · 119 lines
"""Camera states: reusable poses built with state_2d or captured, saved by name and restored.

A ``CameraState`` is a pose (center, zoom and rotation) the camera can cut or
animate to as many times as needed. The outlines on the map show the region
each state frames; a strip pinned to the screen names the call of each shot.
"""

import math
import os

from gaanim import Scene, Zones

MOVE = 1.2
HOLD = 0.5
SPACING = 3.0
SEEKS = []

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


def world(columns=range(-4, 5), rows=range(-2, 3)):
    """Tiles every ``SPACING`` units, each labelled with the coordinates of its center."""
    for column in columns:
        for row in rows:
            x, y = column * SPACING, row * SPACING
            scene.geometry.rounded_rect(2.8, 2.8, 0.16).no_fill().stroke(rule, 0.02).move_to(x, y)
            scene.text(f"({x:g}, {y:g})", role="code").fill(muted).scale_to(1.3).move_to(x, y - 0.95)


def outline(name, center, zoom, rotation=0.0):
    """Outline the region a state with this ``center``, ``zoom`` and ``rotation`` frames, named below it."""
    (x, y), width, height = center, 16 / zoom, 9 / zoom
    frame = scene.geometry.rect(width, height).no_fill().stroke(accent, 0.05).move_to(x, y).rotate_to(rotation)
    # The name sits just outside the bottom edge and turns with it, so it
    # leaves the view when the camera reaches the state.
    below = height / 2 + 0.35
    label = scene.text(name, role="code").fill(accent).scale_to(1.3).rotate_to(rotation)
    label.move_to(x + below * math.sin(rotation), y - below * math.cos(rotation))
    return [frame.animate.create(), label.animate.fade_in()]


class Captions:
    """A strip pinned to the bottom of the screen whose caption names each shot."""

    def __init__(self):
        strip = scene.layout.zones(Zones.rows(["1fr", "80px"]))[1]
        scene.geometry.rect(strip.width, strip.height).fill(panel).no_stroke().hud().move_to(*strip.center)
        self.zone = strip.inset((0, "page_padding"))
        self.current = None

    def shot(self, label, *moves, duration=MOVE, hold=HOLD, snap=True):
        """Show ``label`` and play ``moves`` under it, each lasting ``duration``.

        Records the end of the shot for the snapshots unless ``snap`` is off.
        """
        caption = scene.text(label, role="code").hud().place(self.zone, anchor="left").hidden()
        swap = [caption.animate.fade_in().duration(0.2)]
        if self.current is not None:
            swap.append(self.current.animate.fade_out().duration(0.15))
        self.current = caption
        scene.play(swap + [move.duration(duration) for move in moves])
        scene.wait(hold)
        if snap:
            SEEKS.append(scene.cursor - 0.2)


# state_2d builds a pose without moving the camera, and capture() records the
# pose the camera has at the cursor. animate.to(state) moves there; to(state)
# cuts there. A state can be reused any number of times.
scene.segment("state_2d")
world()
scene.geometry.circle(0.5).fill(success).move_to(-3, 3.0)
scene.geometry.square(1.0).fill(danger).move_to(3, 0.3)
captions = Captions()
overview = scene.camera.capture()
captions.shot("overview = scene.camera.capture()", snap=False)
detail = scene.camera.state_2d(center=(-3, 2.2), zoom=3)
captions.shot("detail = scene.camera.state_2d(center=(-3, 2.2), zoom=3)", *outline("detail", (-3, 2.2), 3))
tilted = scene.camera.state_2d(center=(3, -0.5), zoom=2.5, rotation=0.3)
captions.shot(
    "tilted = state_2d(center=(3, -0.5), zoom=2.5, rotation=0.3)",
    *outline("tilted", (3, -0.5), 2.5, 0.3),
)
captions.shot("scene.camera.animate.to(detail)", scene.camera.animate.to(detail))
captions.shot("scene.camera.animate.to(tilted)", scene.camera.animate.to(tilted))
captions.shot("scene.camera.animate.to(overview)", scene.camera.animate.to(overview))
scene.camera.to(detail)
captions.shot("scene.camera.to(detail)")
captions.shot("scene.camera.animate.to(overview)", scene.camera.animate.to(overview))

# save(name) captures the pose under a name and returns its state;
# restore(name) cuts back to it and animate.restore(name) moves back to it.
scene.segment("save_restore")
scene.camera.reset()
world()
star = scene.geometry.star(5, 0.8, 0.36).fill(accent).move_to(-6, 0.35)
captions = Captions()
scene.camera.save("start")
captions.shot('scene.camera.save("start")', snap=False)
captions.shot("scene.camera.animate.frame_to(star, margin=1)", scene.camera.animate.frame_to(star, margin=1))
captions.shot("scene.camera.animate.rotate_to(-0.3)", scene.camera.animate.rotate_to(-0.3))
scene.camera.save("star")
captions.shot('scene.camera.save("star")', snap=False)
captions.shot('scene.camera.animate.restore("start")', scene.camera.animate.restore("start"))
scene.camera.restore("star")
captions.shot('scene.camera.restore("star")')
captions.shot('scene.camera.animate.restore("start")', scene.camera.animate.restore("start"))

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

Camera shots

Drive the camera along a path, whip pan with motion blur, and shake it with the trauma model.

Camera shots
View on GitHubgaanim examples/camera/camera_shots.py
Code · 156 lines
"""Camera shots: travelling along a path, whip pans and shakes.

``follow_path`` drives the camera along any outline, ``whip_pan`` jumps to a
new subject in a blink with motion blur, and ``shake`` jolts the view and
settles it back. The camera looks at a map of tiles labelled with their
coordinates; a strip pinned to the screen names the call of each shot.
"""

import math
import os

from gaanim import Scene, Zones

HOLD = 0.4
SPACING = 3.0
SEEKS = []

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


def world(columns=range(-7, 8), rows=range(-4, 5)):
    """Tiles every ``SPACING`` units, each labelled with the coordinates of its center."""
    for column in columns:
        for row in rows:
            x, y = column * SPACING, row * SPACING
            scene.geometry.rounded_rect(2.8, 2.8, 0.16).no_fill().stroke(rule, 0.02).move_to(x, y)
            scene.text(f"({x:g}, {y:g})", role="code").fill(muted).scale_to(1.3).move_to(x, y - 0.95)


class Captions:
    """A strip pinned to the bottom of the screen whose caption names each shot."""

    def __init__(self):
        strip = scene.layout.zones(Zones.rows(["1fr", "80px"]))[1]
        scene.geometry.rect(strip.width, strip.height).fill(panel).no_stroke().hud().move_to(*strip.center)
        self.zone = strip.inset((0, "page_padding"))
        self.current = None

    def shot(self, label, *moves, duration=None, hold=HOLD, lead=0.0, peek=None, end=True):
        """Show ``label`` and play ``moves`` under it, ``lead`` seconds later, lasting ``duration`` when given.

        Records for the snapshots the instant ``peek`` seconds after the moves
        start when given, and the end of the shot unless ``end`` is off.
        """
        caption = scene.text(label, role="code").hud().place(self.zone, anchor="left").hidden()
        swap = [caption.animate.fade_in().duration(0.2)]
        if self.current is not None:
            swap.append(self.current.animate.fade_out().duration(0.15))
        self.current = caption
        if peek is not None:
            SEEKS.append(scene.cursor + lead + peek)
        moves = [move if duration is None else move.duration(duration) for move in moves]
        scene.play(swap + [move.delay(lead) for move in moves])
        scene.wait(hold)
        if end:
            SEEKS.append(scene.cursor - 0.15)


def crosshair():
    """Mark the center of the screen; hud() keeps it there while the camera moves."""
    for dx, dy in ((1, 0), (0, 1)):
        scene.geometry.line(-0.5 * dx, -0.5 * dy, 0.5 * dx, 0.5 * dy).stroke(accent, 0.05).hud()


# follow_path moves the camera along the path of a drawable. orient=True turns
# the view so the direction of travel points right, rotate_offset adds to that
# turn (-math.pi / 2 points it up, like a driving view), and start/end choose
# the stretch travelled, backwards when start > end. The default easing is smooth.
scene.segment("follow_path")
world()
road = scene.geometry.path([
    ("move", [(-15, -6)]),
    ("cubic", [(-9, 9), (-3, 6), (0, 0)]),
    ("cubic", [(3, -6), (9, -9), (15, 6)]),
]).no_fill().stroke(rule, 0.3)
crosshair()
captions = Captions()
TRIP = 3.0
captions.shot(
    "scene.camera.animate.follow_path(road)",
    scene.camera.animate.follow_path(road),
    duration=TRIP,
    peek=TRIP / 2,
)
captions.shot(
    "follow_path(road, orient=True, start=1, end=0)",
    scene.camera.animate.follow_path(road, orient=True, start=1, end=0),
    duration=TRIP,
    peek=TRIP / 2,
)
captions.shot(
    "follow_path(road, orient=True, rotate_offset=-math.pi / 2)",
    scene.camera.animate.follow_path(road, orient=True, rotate_offset=-math.pi / 2),
    duration=TRIP,
    peek=TRIP / 2,
)
captions.shot("scene.camera.animate.reset()", scene.camera.animate.reset(), duration=1.0)

# whip_pan takes pan_to's targets and lasts 0.4 s unless told otherwise, with
# most of the travel in a blink around the middle. Exported frames and
# snapshots of the move get motion blur unless blur=False.
scene.segment("whip_pan")
scene.camera.reset()
world(columns=range(-8, 9), rows=range(-2, 3))
scene.geometry.circle(1.0).fill(success).move_to(-18, 0.3)
square = scene.geometry.square(1.8).fill(danger).move_to(0, 0.3)
star = scene.geometry.star(5, 1.2, 0.5).fill(accent).move_to(18, 0.3)
captions = Captions()
captions.shot("scene.camera.animate.whip_pan(star)", scene.camera.animate.whip_pan(star), peek=0.2)
captions.shot("whip_pan(-18, 0, blur=False)", scene.camera.animate.whip_pan(-18, 0, blur=False), peek=0.2)
captions.shot("whip_pan(square).duration(1.2)", scene.camera.animate.whip_pan(square).duration(1.2), peek=0.6)

# shake is deterministic and returns to rest. By default it follows a trauma
# model: trauma (0..1) decays by decay per second and the offset grows with
# trauma squared, up to amplitude units and rotation radians, from noise
# sampled at frequency Hz. Passing only amplitude (and frequency) keeps the
# older sine shake. Offsets are in scene units, so a zoomed-in view shakes
# harder on screen. The crosshair stays on the screen's center, so the ring at
# the origin shows how far the view moves.
scene.segment("shake")
scene.camera.reset()
world(columns=range(-4, 5), rows=range(-2, 3))
scene.geometry.circle(0.35).no_fill().stroke(success, 0.05)
crosshair()
captions = Captions()
scene.camera.zoom_to(2)
captions.shot("scene.camera.zoom_to(2)", end=False)


def jolt(label, shake, peek):
    """Shake shortly after the caption appears; snapshot ``peek`` seconds into it."""
    captions.shot(label, shake, lead=0.3, peek=peek, end=False)


jolt("scene.camera.animate.shake()", scene.camera.animate.shake(), 0.12)
jolt("shake(trauma=0.4)", scene.camera.animate.shake(trauma=0.4), 0.08)
jolt("shake(trauma=1, decay=0.5)", scene.camera.animate.shake(trauma=1, decay=0.5), 0.3)
jolt(
    "shake(amplitude=1.2, rotation=0.1, seed=7)",
    scene.camera.animate.shake(amplitude=1.2, rotation=0.1, seed=7),
    0.12,
)
jolt("shake(frequency=3)", scene.camera.animate.shake(frequency=3), 0.2)
jolt("shake(0.4, 6)", scene.camera.animate.shake(0.4, 6), 0.12)

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

Camera bindings

Bind the camera's center, zoom and rotation to drawables and reactive values, blended by influence and switched on and off.

Camera bindings
View on GitHubgaanim examples/camera/camera_bindings.py
Code · 135 lines
"""Camera bindings: constraints that tie the camera to drawables and reactive values.

``bind_2d`` keeps writing the camera's center, zoom or rotation every frame,
across plays and segments, until it is disabled. Each binding is a
``CameraConstraint`` with ``enable`` and ``disable``; ``influence`` blends it
with the pose below it. A strip pinned to the bottom of the screen names each
call, and one at the top shows the values that drive the camera.
"""

import os

from gaanim import Scene, Zones, computed

MOVE = 1.4
HOLD = 0.4
SPACING = 3.0
SEEKS = []

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


def world(columns=range(-5, 6), rows=range(-3, 4)):
    """Tiles every ``SPACING`` units, each labelled with the coordinates of its center."""
    for column in columns:
        for row in rows:
            x, y = column * SPACING, row * SPACING
            scene.geometry.rounded_rect(2.8, 2.8, 0.16).no_fill().stroke(rule, 0.02).move_to(x, y)
            scene.text(f"({x:g}, {y:g})", role="code").fill(muted).scale_to(1.3).move_to(x, y - 0.95)


def strip(zone):
    """A panel pinned to the screen over ``zone``; returns the zone inset for its contents."""
    scene.geometry.rect(zone.width, zone.height).fill(panel).no_stroke().hud().move_to(*zone.center)
    return zone.inset((0, "page_padding"))


class Captions:
    """Strips pinned to the screen: the caption of each shot at the bottom, the values at the top."""

    def __init__(self, *values):
        top, _, bottom = scene.layout.zones(Zones.rows(["80px", "1fr", "80px"]))
        self.zone = strip(bottom)
        self.current = None
        if values:
            zone = strip(top)
            readouts = [
                scene.viz.readout(value, label=name, color=accent, font="JetBrains Mono", font_size=0.36).hud()
                for name, value in values
            ]
            zone.arrange(*readouts, justify="end", gap="48px")

    def shot(self, label, *moves, duration=MOVE, hold=HOLD, snap=True, midway=False):
        """Show ``label`` and play ``moves`` under it, each lasting ``duration``.

        Records the end of the shot for the snapshots unless ``snap`` is off,
        and its middle too when ``midway`` is set.
        """
        caption = scene.text(label, role="code").hud().place(self.zone, anchor="left").hidden()
        swap = [caption.animate.fade_in().duration(0.2)]
        if self.current is not None:
            swap.append(self.current.animate.fade_out().duration(0.15))
        self.current = caption
        if midway:
            SEEKS.append(scene.cursor + duration / 2)
        scene.play(swap + [move.duration(duration) for move in moves])
        scene.wait(hold)
        if snap:
            SEEKS.append(scene.cursor - 0.15)


# A binding to a drawable keeps it in the center through every later move.
# disable() and enable() switch it at the cursor: the camera cuts back to its
# own pose, or onto the binding again.
scene.segment("center")
world()
probe = scene.geometry.dot(0.4).fill(accent)
captions = Captions()
rig = scene.camera.bind_2d(center=probe)
captions.shot("rig = scene.camera.bind_2d(center=probe)", snap=False)
captions.shot("probe.animate.move_to(9, 3)", probe.animate.move_to(9, 3), midway=True)
captions.shot("probe.animate.move_to(-6, -3)", probe.animate.move_to(-6, -3))
rig.disable()
captions.shot("rig.disable()")
captions.shot("probe.animate.move_to(3, 3)", probe.animate.move_to(3, 3))
rig.enable()
captions.shot("rig.enable()")

# Zoom and rotation take any scalar source: a number, a Parameter, or a
# Computed of other sources (scene.time too). Animating the source moves the
# camera. Bindings outlive their segment, so the last one is disabled first.
scene.segment("zoom_rotation")
rig.disable()
world()
zoom = scene.viz.parameter(1.0)
spin = computed(lambda z: (z - 1) / 4, inputs=[zoom])
scene.geometry.dot(0.4).fill(accent)
captions = Captions(("zoom", zoom), ("spin", spin))
captions.shot("zoom = scene.viz.parameter(1.0)", snap=False)
captions.shot("spin = computed(lambda z: (z - 1) / 4, inputs=[zoom])", snap=False)
rig = scene.camera.bind_2d(zoom=zoom, rotation=spin)
captions.shot("rig = scene.camera.bind_2d(zoom=zoom, rotation=spin)", snap=False)
captions.shot("zoom.animate.set(3)", zoom.animate.set(3), midway=True)
captions.shot("zoom.animate.set(0.6)", zoom.animate.set(0.6))

# Later bindings compose over earlier ones, each weighted by its influence
# (0..1). A binding created with enabled=False waits for enable().
scene.segment("influence")
rig.disable()
world()
circle = scene.geometry.circle(0.8).fill(success).move_to(-6, 3.3)
square = scene.geometry.square(1.4).fill(danger).move_to(6, -2.7)
mix = scene.viz.parameter(0.0)
captions = Captions(("mix", mix))
scene.camera.bind_2d(center=circle)
captions.shot("scene.camera.bind_2d(center=circle)")
scene.camera.bind_2d(center=square, influence=mix)
captions.shot("scene.camera.bind_2d(center=square, influence=mix)", snap=False)
captions.shot("mix.animate.set(1)", mix.animate.set(1))
captions.shot("mix.animate.set(0.5)", mix.animate.set(0.5))
wide = scene.camera.bind_2d(zoom=0.6, enabled=False)
captions.shot("wide = scene.camera.bind_2d(zoom=0.6, enabled=False)", snap=False)
wide.enable()
captions.shot("wide.enable()")

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

Camera insets

Insets that enlarge a detail in a screen joined to it: placement, shapes, view moves, following and fixed screens.

Camera insets
View on GitHubgaanim examples/camera/camera_insets.py
Code · 131 lines
"""Camera insets: enlarged details of the scene in screens joined to what they show.

``scene.camera.inset`` builds a screen, a frame on the target that outlines
what the screen shows, and connectors between them, and returns a
``CameraView``. The map's labels are too small to read until an inset
magnifies them; a strip pinned to the screen names the call of each shot.
"""

import os

from gaanim import Anchor, Scene, Zones

HOLD = 0.8
SEEKS = []

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


def world(columns=range(-10, 11), rows=range(-5, 6)):
    """Unit tiles, each labelled with its coordinates in print too small to read unmagnified."""
    for x in columns:
        for y in rows:
            scene.geometry.rounded_rect(0.92, 0.92, 0.06).no_fill().stroke(rule, 0.01).move_to(x, y)
            scene.text(f"({x}, {y})", role="code").fill(muted).scale_to(0.4).move_to(x, y)


class Captions:
    """A strip pinned to the bottom of the screen: the caption of each shot on the left, values on the right."""

    def __init__(self, *values):
        strip = scene.layout.zones(Zones.rows(["1fr", "80px"]))[1]
        scene.geometry.rect(strip.width, strip.height).fill(panel).no_stroke().hud().move_to(*strip.center)
        self.zone = strip.inset((0, "page_padding"))
        self.current = None
        if values:
            readouts = [
                scene.viz.readout(value, label=name, color=accent, font="JetBrains Mono", font_size=0.36).hud()
                for name, value in values
            ]
            self.zone.arrange(*readouts, justify="end", gap="48px")

    def shot(self, label, *moves, duration=0.8, hold=HOLD, snap=True):
        """Show ``label`` and play ``moves`` under it, each lasting ``duration``; snapshot the hold."""
        caption = scene.text(label, role="code").hud().place(self.zone, anchor="left").hidden()
        swap = [caption.animate.fade_in().duration(0.2)]
        if self.current is not None:
            swap.append(self.current.animate.fade_out().duration(0.15))
        self.current = caption
        scene.play(swap + [move.duration(duration) for move in moves])
        scene.wait(hold)
        if snap:
            SEEKS.append(scene.cursor - hold / 2)


def show(label, view):
    """Pop ``view`` out of the region it shows under ``label``, hold it, and pop it back in."""
    captions.shot(label, view.animate.pop_out())
    scene.play([view.animate.pop_in().duration(0.5)])


# The target is a point, a drawable or an anchor point. at places the screen
# against a corner or edge of the frame, or centers it on a point; shape is
# "rounded" (the default), "rect" or "circle"; size is the screen's width (a
# circle's diameter), 30% of the frame by default, or (width, height); aspect
# sets the proportions; color strokes screen, frame and connectors.
scene.segment("placement")
world()
captions = Captions()
show("scene.camera.inset((-5, 2), zoom=4)", scene.camera.inset((-5, 2), zoom=4))
show(
    'inset((-3, -2), zoom=5, at=Anchor.RIGHT, shape="circle")',
    scene.camera.inset((-3, -2), zoom=5, at=Anchor.RIGHT, shape="circle"),
)
show(
    'inset((3, 2), zoom=3, at=Anchor.LEFT, shape="rect", aspect=1)',
    scene.camera.inset((3, 2), zoom=3, at=Anchor.LEFT, shape="rect", aspect=1),
)
show(
    "inset((0, -3), zoom=4, at=Anchor.TOP, size=(10, 2.4))",
    scene.camera.inset((0, -3), zoom=4, at=Anchor.TOP, size=(10, 2.4)),
)
show(
    "inset((5, -2), zoom=4, at=(-3, 1), connectors=False, color=success)",
    scene.camera.inset((5, -2), zoom=4, at=(-3, 1), connectors=False, color=success),
)

# A CameraView animates like the main camera: pan_to (a point or a drawable),
# zoom_to, at a constant perceived speed, and rotate_to, which turns the frame
# and the view the other way. view.zoom is a reactive value for readouts.
scene.segment("view_moves")
world()
probe = scene.geometry.dot(0.06).fill(accent).move_to(3.5, -2.5)
view = scene.camera.inset((-6, 3), zoom=4)
captions = Captions(("view.zoom", view.zoom))
captions.shot("view = scene.camera.inset((-6, 3), zoom=4)", view.animate.pop_out())
captions.shot("view.animate.pan_to(-2, 1)", view.animate.pan_to(-2, 1))
captions.shot("view.animate.zoom_to(8)", view.animate.zoom_to(8))
captions.shot("view.animate.rotate_to(0.4)", view.animate.rotate_to(0.4))
captions.shot("view.animate.pan_to(probe)", view.animate.pan_to(probe))
captions.shot("view.animate.pop_in()", view.animate.pop_in(), snap=False)

# follow=True keeps the frame on a moving target. fixed=True pins the screen
# to the output like a HUD overlay: when the main camera moves, the fixed
# screen stays put while the other one moves with the scene.
scene.segment("follow_fixed")
scene.camera.reset()
world()
probe = scene.geometry.dot(0.06).fill(accent).move_to(2.5, -2.3)
pinned = scene.camera.inset(probe, zoom=3, follow=True, fixed=True)
loose = scene.camera.inset((-5, -2), zoom=3, at=Anchor.TOP_LEFT, color=success)
captions = Captions()
captions.shot(
    "pinned = scene.camera.inset(probe, zoom=3, follow=True, fixed=True)",
    pinned.animate.pop_out(),
    loose.animate.pop_out(),
)
captions.shot("loose = scene.camera.inset((-5, -2), zoom=3, at=Anchor.TOP_LEFT)", snap=False)
captions.shot("probe.animate.move_to(5.5, -0.3)", probe.animate.move_to(5.5, -0.3), duration=1.6)
captions.shot("scene.camera.animate.pan_to(-2, -1)", scene.camera.animate.pan_to(-2, -1), duration=1.2)
captions.shot("scene.camera.animate.zoom_to(1.4)", scene.camera.animate.zoom_to(1.4), duration=1.2)

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

Camera views

Shapes that show what a second camera sees: frames, fit modes, lenses and X-ray view layers.

Camera views
View on GitHubgaanim examples/camera/camera_views.py
Code · 145 lines
"""Camera views: shapes that show what a second camera sees, like a picture-in-picture or a lens.

``drawable.camera_view(...)`` turns a closed shape into a screen. Its camera
looks through a frame drawable, or at a center with a zoom of its own, and can
show view layers the main camera hides. The map's labels are too small to read
until a view magnifies them; a strip pinned to the screen names each call.
"""

import os

from gaanim import Scene, Zones

MOVE = 1.2
HOLD = 0.5
SEEKS = []

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

page = scene.layout.zones(Zones.rows(["1fr", "80px"]))
stage = page[0].inset("page_padding")


def world(zone):
    """Unit tiles inside ``zone``, each labelled with its coordinates in print too small to read."""
    for x in range(round(zone.left + 0.5), round(zone.right - 0.5) + 1):
        for y in range(round(zone.bottom + 0.5), round(zone.top - 0.5) + 1):
            scene.geometry.rounded_rect(0.92, 0.92, 0.06).no_fill().stroke(rule, 0.01).move_to(x, y)
            scene.text(f"({x}, {y})", role="code").fill(muted).scale_to(0.4).move_to(x, y)


class Captions:
    """A strip pinned to the bottom of the screen whose caption names each shot."""

    def __init__(self):
        strip = page[1]
        scene.geometry.rect(strip.width, strip.height).fill(panel).no_stroke().hud().move_to(*strip.center)
        self.zone = strip.inset((0, "page_padding"))
        self.current = None

    def shot(self, label, *moves, duration=MOVE, hold=HOLD, snap=True):
        """Show ``label`` and play ``moves`` under it, each lasting ``duration``; snapshot the hold."""
        caption = scene.text(label, role="code").hud().place(self.zone, anchor="left").hidden()
        swap = [caption.animate.fade_in().duration(0.2)]
        if self.current is not None:
            swap.append(self.current.animate.fade_out().duration(0.15))
        self.current = caption
        scene.play(swap + [move.duration(duration) for move in moves])
        scene.wait(hold)
        if snap:
            SEEKS.append(scene.cursor - hold / 2)


# The frame is the camera: moving it pans the view, scaling it down zooms in
# (the zoom is the screen's size over the frame's) and turning it turns the
# view the other way. The screen and the frame stay out of the view.
scene.segment("frame")
left, right = stage.split(Zones.columns(["2fr", "3fr"], gap="48px"))
world(left)
frame = scene.geometry.rect(2.4, 1.35).no_fill().stroke(accent, 0.03).move_to(-4, 1)
screen = scene.geometry.rounded_rect(right.width, right.width * 9 / 16, 0.12).stroke(accent, 0.05)
screen.move_to(*right.center)
view = screen.camera_view(frame)
captions = Captions()
captions.shot("view = screen.camera_view(frame)", screen.animate.fade_in())
captions.shot("frame.animate.scale_to(0.5)", frame.animate.scale_to(0.5))
captions.shot("frame.animate.move_to(-6, -2)", frame.animate.move_to(-6, -2))
captions.shot("frame.animate.rotate_to(0.5)", frame.animate.rotate_to(0.5))

# When the screen's proportions differ from the frame's, fit decides: "contain"
# keeps the whole frame in view, "cover" fills the screen and crops, and
# "stretch" scales each axis on its own.
scene.segment("fit")
left, right = stage.split(Zones.columns(["1fr", "2fr"], gap="48px"))
world(left)
frame = scene.geometry.rect(2.4, 1.35).no_fill().stroke(accent, 0.03).move_to(-5, 1)
columns = right.split(Zones.columns(3, gap="32px"))
for column, fit in zip(columns, ["contain", "cover", "stretch"]):
    body, foot = column.split(Zones.rows(["1fr", 0.8]))
    side = min(body.width, body.height)
    square = scene.geometry.rounded_rect(side, side, 0.12).stroke(accent, 0.05).move_to(*body.center)
    square.camera_view(frame, fit=fit)
    scene.text(f'fit="{fit}"', role="code").place(foot, anchor="center")
captions = Captions()
captions.shot("frame.animate.move_to(-5, -2)", frame.animate.move_to(-5, -2), duration=2.0)

# Without a frame, the camera looks at center with zoom of its own. The view
# animates like the main camera: pan_to, zoom_to at a constant perceived speed
# and rotate_to. Here the lens and its view travel together.
scene.segment("lens")
world(stage)
lens = scene.geometry.circle(1.6).stroke(accent, 0.05).move_to(-5, 1)
view = lens.camera_view(center=(-5, 1), zoom=3)
captions = Captions()
captions.shot("view = lens.camera_view(center=(-5, 1), zoom=3)", lens.animate.fade_in())
captions.shot(
    "lens.animate.move_to(1, -1), view.animate.pan_to(1, -1)",
    lens.animate.move_to(1, -1),
    view.animate.pan_to(1, -1),
    duration=1.6,
)
captions.shot("view.animate.zoom_to(6)", view.animate.zoom_to(6))
captions.shot("view.animate.rotate_to(0.6)", view.animate.rotate_to(0.6))

# view_layer("xray") takes drawables out of the main camera: only views that
# list the layer show them. exclude hides drawables from the view, here the
# lids over the hidden shapes, and background=None lets the screen's own fill
# show behind the view instead of the scene background.
scene.segment("layers")
world(stage)
lids = [
    scene.geometry.rounded_rect(3.2, 3.2, 0.2).fill(panel).stroke(rule, 0.03).move_to(x, 0)
    for x in (-5, 0, 5)
]
scene.geometry.star(5, 1.0, 0.45).fill(accent).move_to(-5, 0).view_layer("xray")
scene.geometry.circle(0.9).fill(success).move_to(0, 0).view_layer("xray")
scene.geometry.square(1.6).fill(danger).move_to(5, 0).view_layer("xray")
# Without frame or center, the view looks at the origin with a zoom of 1.
lens = scene.geometry.circle(1.8).fill(panel).stroke(accent, 0.05)
view = lens.camera_view(layers=["xray"], exclude=lids, background=None)
captions = Captions()
captions.shot('lens.camera_view(layers=["xray"], exclude=lids, background=None)', lens.animate.fade_in())
captions.shot(
    "lens.animate.move_to(5, 0), view.animate.pan_to(5, 0)",
    lens.animate.move_to(5, 0),
    view.animate.pan_to(5, 0),
    duration=1.6,
)
captions.shot(
    "lens.animate.move_to(-5, 0), view.animate.pan_to(-5, 0)",
    lens.animate.move_to(-5, 0),
    view.animate.pan_to(-5, 0),
    duration=2.0,
)

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

Parallax

Parallax layers at several depths under pan, zoom and rotation, and depth of field with a rack focus.

Parallax
View on GitHubgaanim examples/camera/parallax.py
Code · 129 lines
"""Parallax: layers at different depths drift at their own speed, and depth of field blurs them.

``scene.layer(depth)`` makes a 2.5D layer: its members move by ``1 / depth``
of the camera's pan and zoom by ``zoom ** (1 / depth)``, so far layers drift
slowly and near ones sweep past. Each row of the landscape is one layer,
labelled with the call that made it; a strip pinned to the screen names the
camera call of each shot.
"""

import math
import os

from gaanim import Scene, Zones

MOVE = 1.6
HOLD = 0.5
SEEKS = []

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

page = scene.layout.zones(Zones.rows(["1fr", "80px"]))
bands = page[0].inset("page_padding").split(Zones.rows(5, gap="12px"))


def sun(x, zone):
    return scene.geometry.circle(zone.height * 0.5).fill(accent).move_to(x, zone.center[1])


def mountain(x, zone):
    return scene.geometry.polygon([(x - 1.8, zone.bottom), (x, zone.top), (x + 1.8, zone.bottom)]).fill(rule)


def hill(x, zone):
    return scene.geometry.ellipse(1.3, zone.height / 2).fill(muted).move_to(x, zone.center[1])


def tree(x, zone):
    return scene.geometry.polygon([(x - 0.5, zone.bottom), (x, zone.top), (x + 0.5, zone.bottom)]).fill(success)


def rock(x, zone):
    return scene.geometry.rounded_rect(1.6, zone.height, 0.3).fill(foreground).move_to(x, zone.center[1])


ROWS = [
    ("math.inf", math.inf, sun, 8.0),
    ("4", 4, mountain, 3.6),
    ("2", 2, hill, 3.0),
    ("1", 1, tree, 1.4),
    ("0.6", 0.6, rock, 3.4),
]


def landscape():
    """One layer per band, its label above its shapes; returns the layers."""
    layers = []
    for band, (name, depth, shape, spacing) in zip(bands, ROWS):
        head, body = band.split(Zones.rows([0.42, "1fr"]))
        layer = scene.layer(depth=depth)
        first = band.left + (1.5 if shape is not sun else 6.5)
        layer.add(*[shape(first + spacing * index, body) for index in range(round(30 / spacing))])
        label = scene.text(f"scene.layer(depth={name})", role="code").fill(foreground)
        layer.add(label.place(head, anchor="left"))
        layers.append(layer)
    return layers


class Captions:
    """A strip pinned to the bottom of the screen whose caption names each shot."""

    def __init__(self):
        strip = page[1]
        scene.geometry.rect(strip.width, strip.height).fill(panel).no_stroke().hud().move_to(*strip.center)
        self.zone = strip.inset((0, "page_padding"))
        self.current = None

    def shot(self, label, *moves, duration=MOVE, hold=HOLD, midway=False):
        """Show ``label`` and play ``moves`` under it, each lasting ``duration``; snapshot the hold."""
        caption = scene.text(label, role="code").hud().place(self.zone, anchor="left").hidden()
        swap = [caption.animate.fade_in().duration(0.2)]
        if self.current is not None:
            swap.append(self.current.animate.fade_out().duration(0.15))
        self.current = caption
        if midway:
            SEEKS.append(scene.cursor + duration / 2)
        scene.play(swap + [move.duration(duration) for move in moves])
        scene.wait(hold)
        SEEKS.append(scene.cursor - hold / 2)


# Pans move each layer by 1 / depth of the camera's motion (math.inf never
# moves), zooms scale it by zoom ** (1 / depth), and rotation turns every
# layer alike. A layer is a Drawable: animating it acts on all its members.
scene.segment("layers")
layers = landscape()
captions = Captions()
captions.shot("scene.camera.animate.pan_to(8, 0)", scene.camera.animate.pan_to(8, 0), midway=True)
captions.shot("scene.camera.animate.zoom_to(1.6)", scene.camera.animate.zoom_to(1.6))
captions.shot("scene.camera.animate.rotate_to(0.12)", scene.camera.animate.rotate_to(0.12))
captions.shot("scene.camera.animate.reset()", scene.camera.animate.reset())
captions.shot("layers[2].animate.opacity(0.2)", layers[2].animate.opacity(0.2))

# depth_of_field blurs each layer by aperture * |1 - focus / depth| scene
# units, at most max_blur: the layer at the focus depth stays sharp. focus_to
# racks the focus, moving evenly through near and far layers.
scene.segment("depth_of_field")
scene.camera.reset()
landscape()
captions = Captions()
scene.camera.depth_of_field(focus=0.6, aperture=0.15)
captions.shot("scene.camera.depth_of_field(focus=0.6, aperture=0.15)")
captions.shot("scene.camera.animate.focus_to(4)", scene.camera.animate.focus_to(4), midway=True)
captions.shot(
    "scene.camera.animate.depth_of_field(max_blur=0.05)",
    scene.camera.animate.depth_of_field(max_blur=0.05),
)
captions.shot("scene.camera.animate.depth_of_field(aperture=0)", scene.camera.animate.depth_of_field(aperture=0))

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