# Animation & Video

Tesserax isn't just for static images. It includes a lightweight, code-first animation engine capable of exporting GIFs and MP4s.

> **Note**: To use animation features, ensure you installed Tesserax with the export dependencies: `pip install tesserax[export]`.

## The `Scene` Object

The `Scene` is the director of your animation. It wraps a `Canvas` and manages the render loop, frame capture, and file export.

At its lowest level, you can animate simply by changing shapes in a loop and calling `scene.capture()`. This gives you total control over every frame.

```{python}
from tesserax import Canvas, Square, deg
from tesserax.animation import Scene
from tesserax.color import Colors

# 1. Setup the static scene
with Canvas() as canvas:
    rect = Square(40, fill=Colors.Orange)

canvas.fit(10)

# 2. Animate
scene = Scene(canvas, fps=30)

# We manually drive the loop
for i in range(30):
    rect.rotated(deg(3)) # Rotate 3 degrees per frame
    scene.capture()      # Snap!

# 3. Render
scene.display()
```

## Declarative Animations

While manual loops are powerful, they get messy quickly. Tesserax provides a declarative API where you define *what* happens, not *how*.

The `Scene.play()` method accepts one or more `Animation` objects.

* **Parallel**: Arguments passed to `play(a, b)` run **simultaneously**.
* **Sequential**: Animations added with `a + b` run **one after another**.

We use the `.animate` property on shapes to quickly generate these objects.

```{python}
from tesserax import Circle

with Canvas() as canvas:
    box = Square(30, fill=Colors.LightBlue).translated(-40, 0)
    ball = Circle(15, fill=Colors.Salmon).translated(40, 0)

canvas.fit(10)
scene = Scene(canvas, fps=30)

# Define animations
move_box = box.animate.rotate(deg(90)) | box.animate.translate(20)
fade_ball = ball.animate.scale(0.5)

# Run them together (box moves sequentially, ball scales in parallel)
scene.play(move_box.looping(), fade_ball.looping(), duration=2.0)

scene.display()
```

### Animation Modifiers

You can tweak the timing and behavior of any animation using fluent modifiers:

* `.reversed()`: Plays backwards.
* `.looping()`: Plays forward then backward (yoyo).
* `.repeating(n)`: Repeats `n` times within the duration.
* `.delayed(t)`: Waits for `t` (0.0 to 1.0) before starting.
* `.smoothed()`: Applies an ease-in-out curve (default is linear).

```{python}
from tesserax import Shape

with Canvas() as c:
    b1 = Circle(10, fill=Colors.Red).translated(-30, 0)
    b2 = Circle(10, fill=Colors.Blue).translated(0, 0)
    b3 = Circle(10, fill=Colors.Green).translated(30, 0)

c.fit(40)
scene = Scene(c)

# Create a jump animation
def jump(shape: Shape):
    return (
        shape.animate.translate(0, -40).smoothed() |
        shape.animate.translate(0, 0).smoothed()
    )

scene.play(
    jump(b1),
    jump(b2).delayed(0.2), # Start 20% later
    jump(b3).delayed(0.4), # Start 40% later
    duration=1.5
)

scene.display()
```

## Morphing and Warping

For `Polyline` shapes, Tesserax offers advanced vertex manipulation.

### Morphing

The `.morph(target)` animation smoothly interpolates the points of one shape into another.

```{python}
from tesserax import Polyline

with Canvas() as c:
    # Start as a Triangle
    shape = Polyline.poly(3, 40, fill=Colors.Gold).subdivide()
    # Target is a Hexagon
    target = Polyline.poly(6, 40).hide()

c.fit(10)
scene = Scene(c)

scene.play(
    shape.animate.morph(target).smoothed().looping(),
    duration=2.0
)

scene.display()
```

### Warping

The `.warp(func)` animation allows you to apply a function  to every point in a shape over time. This is perfect for wave effects.

```{python}
import math
from tesserax import Point

with Canvas() as c:
    # Create a dense line so we have points to warp
    line = Polyline([Point(x, 0) for x in range(-50, 50, 2)], stroke=Colors.Blue)

c.fit(15)
scene = Scene(c)

# Define a wave function
def wave(p, t):
    # t goes 0 -> 1
    # We use it to shift the phase
    phase = t * math.pi * 2
    amplitude = 10
    freq = 0.1
    return Point(p.x, math.sin(p.x * freq + phase) * amplitude)

scene.play(
    line.animate.warp(wave).repeating(2),
    duration=2.0
)

canvas.fit(10)
scene.display()
```

## Text Effects

Text objects have their own special animator with effects like `write` (typewriter style) and `scramble` (hacker style).

```{python}
from tesserax import Text

with Canvas() as c:
    t1 = Text("Hello World", size=24).translated(0, -20)
    t2 = Text("Encryption", size=24, fill=Colors.Green, font="monospace").translated(0, 20)

c.fit(10)
scene = Scene(c)

scene.play(
    t1.animate.write(),
    t2.animate.scramble(),
    duration=2.0
)
scene.wait(1.0)

scene.display()
```

## Animating Arbitrary Properties

Sometimes you need to animate a property that Tesserax doesn't have a built-in method for, like the `radius` of a circle, the `gap` of a layout, or a custom attribute you added to a subclass.

### The `property` Method

The `animator.property(name, value)` method allows you to interpolate **any numeric attribute** on the shape from its current value to a target value.

```{python}
from tesserax import Circle

with Canvas() as c:
    # A circle starts with radius 10
    ball = Circle(10, fill=Colors.Red)

c.fit(15)
scene = Scene(c)

scene.play(
    # Explicitly animate the 'r' attribute (radius) to 50
    ball.animate.property("r", 20).looping(),
    duration=1.0
)
scene.display()
```

### Magic Property Access

For even cleaner code, the animator supports **dynamic property access**. If you try to call a method on `.animate` that doesn't exist (e.g., `.radius(...)`), Tesserax assumes you want to animate the property of that name.

And since *any* property can be animated (as long as the underlying value supports scalar arithmetics), you can funny things like animating the smoothness of a `Polyline`.

```{python}
from tesserax import Polyline

with Canvas() as c:
    # Square defined by 'size'
    line = Polyline.poly(5, 50)

c.fit(10)
scene = Scene(c)

scene.play(
    line.animate.width(5).looping(),
    line.animate.smoothness(1).looping(),
    line.animate.rotate(deg(360/5)).smoothed(),
    duration=1.0
)
scene.display()
```

## Custom Animation Logic

When standard interpolation isn't enough, the `.custom()` method gives you a direct hook into the animation loop. You provide a callback function that receives the **shape** and the **time**  (from 0 to 1), and you can do whatever you want.

This is perfect for complex math, interdependent properties, or non-linear behaviors. The fun part is that you add `.looping()` or `.smoothed()` or any other standard `Animation` modifier even to these custom animations.

```{python}
import math

with Canvas() as c:
    ball = Circle(10, fill=Colors.Purple)

c.fit(50)
scene = Scene(c)

# A custom function to move in a spiral
def spiral(shape, t):
    # t goes 0 -> 1
    angle = t * math.pi * 4  # 2 full turns
    radius = t * 50          # Spiral out to 50px

    # Update position manually
    shape.transform.tx = math.cos(angle) * radius
    shape.transform.ty = math.sin(angle) * radius

    # We can also change other properties!
    shape.fill = Colors.Purple.lerp(Colors.Yellow, t)

scene.play(
    ball.animate.custom(spiral).smoothed().looping(),
    duration=3.0
)

scene.display()
```

## Keyframe Animation

For complex motion graphics where an object needs to follow a specific "script" (e.g., "move right, wait, rotate, then shrink"), chaining simple animations becomes tedious.

The `.keyframes()` method allows you to define a timeline for multiple properties simultaneously in a single call.

### The Timeline API

You pass properties as keyword arguments. Each argument accepts a dictionary mapping a **normalized time** ($0.0 \to 1.0$) to a **target value**.

* If $0.0$ is missing, Tesserax infers it from the current state.
* Tesserax automatically detects if the property belongs to the `Shape` (like `fill`) or its `Transform` (like `tx`, `rotation`).

```{python}
from tesserax.animation import ease_out, ease_in_out_cubic, linear

with Canvas() as c:
    box = Square(30, fill=Colors.Orange)

c.fit(50)
scene = Scene(c)

scene.play(
    box.animate.keyframes(
        # Track 1: Movement (Transform property)
        tx={
            0.0: -20,
            0.4: 20, # Move fast to 100
            0.6: 20, # Stay there for 20% of time
            1.0: -20,    # Return home
        },
        # Track 2: Rotation (Transform property)
        # We can use (value, easing) tuples for precise control
        rotation={
            0.0: 0,
            1.0: (deg(360), ease_in_out_cubic) # Spin fully with smooth accel/decel
        },
        # Track 3: Color (Shape property)
        fill={
            0.5: Colors.Orange,
            0.8: Colors.Red, # Turn red near the end
            1.0: Colors.Orange
        }
    ),
    duration=3.0
)

scene.display()
```

By default, Tesserax interpolates linearly between keyframes. However, you often want specific segments to feel different (e.g., a "snap" effect).

You can pass a tuple `(value, easing_function)` instead of just a raw value. The easing function applies to the time interval **leading up to** that keyframe.
