> built 2026-10-14 05:36 UTC from 29ff63e (main) · burns 0.0.19. Details: build_info.json

# index.html.md

<!-- generated by epythet -->

# burns

Ken Burns pan/zoom video effects: turn a still image — or a sequence of stills —
into a cinematic pan/zoom film.

The [Ken Burns effect](https://en.wikipedia.org/wiki/Ken_Burns_effect) animates a
static photograph by slowly panning across it and zooming in or out, giving still
images a sense of motion. `burns` does exactly that, with a tiny API and no
configuration required — and a clean, render-agnostic motion spec underneath so
the same path drives the Python renderer here and the TypeScript one in `ts/`.

```bash
pip install burns
```

No system `ffmpeg` is required: `moviepy` brings its own through `imageio-ffmpeg`,
and both render backends use that binary by default. (An earlier version of this
paragraph said otherwise; it was never true.)

## Two render backends

`ken_burns_video(..., backend=...)` picks how the frames are made:

| backend                 | how                                                                                                                          | when                                                                  |
|-------------------------|------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| `"pillow"`  *(default)* | one `moviepy` frame at a time, resampled in Python                                                                           | the quality default, and the only one with no constraints on the path |
| `"ffmpeg"`              | one process: the path is compiled to a filter graph by [`looks`](https://github.com/thorwhalen/looks) and handed to `ffmpeg` | when you want a single decode/encode instead of per-frame Python      |

The split follows the rule the two packages share: **`burns` owns the authored
geometry, `looks` owns compiling it, and `burns` runs the argv.** `looks` never
starts a process that produces media — that is its own invariant.

The two are **not pixel-identical**: measured ~52 dB apart on a smooth image and
~34 dB on hard edges, dominated by resampler choice (Pillow’s bicubic against
ffmpeg’s scaler) rather than by framing. `pillow` stays the default so switching
is a decision rather than a surprise, and the ffmpeg path refuses — naming
`backend="pillow"` — any path it cannot express, rather than rendering
something else.

## Demo

Starting from a single still image:

![input still image](https://raw.githubusercontent.com/thorwhalen/burns/main/assets/demo_landscape.jpg)

…two lines of code turn it into two different Ken Burns films — a slow zoom-in
(“push”) and a lateral pan (“drift”):

```python
from burns import ken_burns_video, ken_burns_path

ken_burns_video(
    "demo_landscape.jpg", ken_burns_path(1, zoom=1.4, pan=0.06), duration=4.0
)
ken_burns_video(
    "demo_landscape.jpg", ken_burns_path(2, style="drift", pan=0.14), duration=4.0
)
```

| `style="push"` — eased zoom-in                                                        | `style="drift"` — lateral pan                                                           |
|---------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
| ![push](https://raw.githubusercontent.com/thorwhalen/burns/main/assets/demo_push.gif) | ![drift](https://raw.githubusercontent.com/thorwhalen/burns/main/assets/demo_drift.gif) |

The full script that generated this still and these GIFs is
[`misc/generate_demo.py`]().

## Quickstart

A standard 2-second push-in, written next to the source image:

```python
from burns import ken_burns_video

ken_burns_video("photo.jpg")  # → photo_kenburns.mp4
```

That’s it. The result is an mp4 that slowly zooms into the center of `photo.jpg`.

## The motion spec: `BurnsPath`

The camera motion is a `BurnsPath` — a pure, time-parameterized spec. Its core is
`evaluate(t) -> Rect` for `t ∈ [0, 1]`: where the viewport is at each instant,
independent of any renderer, frame rate, or duration.

A *rect* is `Rect(x, y, w, h)` — a normalized window over the image, top-left
origin, every component in `[0, 1]`. `Rect(0, 0, 1, 1)` is the whole image; a
smaller `w`/`h` is zoomed in. The common cases have one-liners:

```python
from burns import ken_burns_video, BurnsPath, Rect

# The 90% case: push from the full image toward a point at a given zoom.
ken_burns_video("photo.jpg", BurnsPath.push_in(1.3, to=(0.65, 0.40)), duration=5.0)

# The canonical two-rectangle (Start → End) case, full control:
path = BurnsPath.from_start_end(
    Rect(0, 0, 1, 1),  # start: whole image
    Rect.from_center_zoom(0.65, 0.40, 1.2),  # end: zoomed toward upper-right
    easing="ease-in-out",  # the cinematic default
)
ken_burns_video("photo.jpg", path, duration=5.0, saveas="out.mp4")

# N keyframes for a multi-beat move (a hold = two equal keyframes):
path = BurnsPath(
    keyframes=[
        (0.0, Rect(0, 0, 1, 1)),
        (0.5, Rect.from_center_zoom(0.65, 0.40, 1.2)),
        (1.0, Rect.from_center_zoom(0.35, 0.60, 1.3)),
    ]
)
```

**Easing** is a CSS timing function (`"linear"`, `"ease-in-out"` (default),
`"cubic-bezier(...)"`, or any callable) and is composed *over* the geometry —
motion shape and motion speed stay orthogonal.

**Output aspect ratio is independent of the source image.** Set `output_aspect`
to make a widescreen clip from a portrait photo (the renderer cover-crops, never
stretches):

```python
ken_burns_video(
    "portrait.jpg", BurnsPath.push_in(1.4, output_aspect=16 / 9), duration=6.0
)
```

## Let `burns` design the motion for you

Hand-authoring rectangles for every image gets tedious. `ken_burns_path` generates
a cohesive, **deterministic, non-repetitive** path from a little intent — pass the
image’s position (`index`) and it picks the framing. Duration is supplied at render
time, so a path is reusable across clip lengths:

```python
from burns import ken_burns_video, ken_burns_path

# index seeds the focal direction; odd indices push in, even pull out.
ken_burns_video("photo.jpg", ken_burns_path(1), duration=5.0)

# styles: "push" (zoom-led, the default) or "drift" (pure horizontal pan)
ken_burns_video("photo.jpg", ken_burns_path(2, style="drift"), duration=5.0)

# easing controls the velocity curve (default "ease-in-out"); "linear" is constant
ken_burns_video("photo.jpg", ken_burns_path(1, easing="linear"), duration=6.0)
```

## Content-aware motion

`ken_burns_path` frames by index, not by what is in the picture — so it will
happily drift across empty sky. `content_aware_path_for` looks at the image
first and builds a path that keeps the subject framed:

```python
from burns import ken_burns_video, content_aware_path_for

ken_burns_video("photo.jpg", content_aware_path_for("photo.jpg", index=1), duration=5.0)
```

No extra install: the subject estimate is a gradient-magnitude (“busyness”)
heuristic over numpy + Pillow, which `burns` already requires. Flat regions —
sky, walls, water — have low gradient and fall away, so the box tracks the
detailed part of the frame.

**Faces, when you have a detector.** `burns` ships no face model. Detection is
*injected*, so you choose the dependency: pass boxes you already have, or a
`faces_detector` callable that returns normalized `(x, y, w, h)` boxes. The
detector always receives a `PIL.Image` — whatever you passed as `image` is
opened or converted first.

```python
# boxes you already have (faces win over the saliency estimate)
path = content_aware_path_for("group.jpg", faces=[(0.31, 0.22, 0.09, 0.12)])

# or a detector — anything callable: OpenCV, ONNX, a vision model, a lookup
path = content_aware_path_for("group.jpg", faces_detector=my_detector, index=2)
```

With neither `faces` nor `faces_detector` you simply get saliency-only,
sky-avoiding motion — no error, no warning, just a less specific keep-region.

**The geometry on its own.** `content_aware_path` is the pixel-free core: give
it the image size and a keep-region and it returns the `BurnsPath`. Reach for it
when the boxes come from somewhere else — a UI, a database, an upstream vision
pipeline.

```python
from burns import content_aware_path

path = content_aware_path(
    1920, 1080, subject=(0.60, 0.55, 0.20, 0.25), index=1, output_aspect=16 / 9
)
```

Start and end windows are both centered on the keep-region (sliding inside the
image edges when they’d overhang) and sized to the output aspect, so the
renderer’s cover-crop is a no-op — what you frame is what shows.

The requested `zoom` is **capped** so the padded keep-region normally stays
inside the frame — but a `min_zoom + 0.02` floor wins over that cap, so a
keep-region that fills the picture is cropped slightly rather than yielding no
motion at all. If a subject that fills the frame must stay whole, tighten the
keep-region (a smaller `keep_pad`, or explicit boxes); raising `zoom` cannot do
it. `index` keeps the same rhythm as `ken_burns_path` (odd pushes in, even pulls
out); `mode="in"` / `mode="out"` overrides it.

## Storing a move: the named vocabulary

A `BurnsPath` is *resolved geometry* — rectangles measured against one picture’s
pixels. That makes it the wrong thing to persist when the picture can change.
What an editor wants to keep is the **authored intent**, resolved against
whatever still is in the slot at render time:

```python
from burns import MOVES, resolve_move

MOVES
# ('push_in', 'pull_out', 'drift_left', 'drift_right',
#  'drift_up', 'drift_down', 'hold', 'auto')

path = resolve_move("push_in", image="photo.jpg", aspect=16 / 9, seed=4021)
```

Replace the still and the move re-frames itself; keep the still and the move is
exactly what it was. So store `(move, zoom, focus, seed)` and call
`resolve_move` at render time — never cache the path it returns.

**One code path, two front doors.** `move` takes a name *or* an explicit
`BurnsPath` (or its `to_dict()` payload), so a panel carrying a hand-corrected
override and a panel carrying a name make the same call:

```python
resolve_move(
    panel.path or panel.move,
    image=still,
    aspect=16 / 9,
    zoom=panel.zoom,
    focus=panel.focus,
    seed=panel.seed,
)
```

An override is returned exactly as authored. If its `output_aspect` contradicts
the `aspect` being rendered, that **raises** rather than cover-cropping somebody’s
hand-drawn framing without saying so — a panel carries *one* path, not one per
delivery, so a hand-corrected move on the 16:9 cut would otherwise silently show
the wrong framing in the vertical cut of the same project. Pass
`on_aspect_mismatch="refit"` to render it anyway: each keyframe is rebuilt at
the new aspect **keeping what the author chose** — where the camera looks at
each instant and how far in it is — and changing only the window’s shape.

```python
resolve_move(panel.path, image=still, aspect=9 / 16, on_aspect_mismatch="refit")
```

**Caching a render? Put `burns.RESOLVER_IMPL_VERSION` in the key.** A stored
panel is an intent, so the pixels it becomes are decided by constants in this
module (`DFLT_ZOOM`, `DRIFT_TRAVEL`, `DRIFT_SPAN`, `DRIFT_MIN_ROOM`,
`AUTO_WEIGHTS`). Retune any of them and an unchanged panel renders differently —
so a key built from the panel alone serves the old frames forever, or produces a
cut that silently disagrees with its siblings. It is `nw.Transform.impl_version`’s
contract, *a lock not a receipt*: bumped when the geometry changes, left alone
for a docstring. Deliberately **not** the package version, so a burns release
that touches only the renderer invalidates nobody’s cut.

**`seed` is not a position.** Deriving motion from a panel’s ordinal (`i % 2` for
the style, `i % 4` for the zoom) means reordering one panel changes the camera on
every panel after it, and “keep this move, change this picture” cannot be said at
all. `resolve_move` is a pure function of its arguments and never of a position:
mint a `seed` once, store it beside the move, and the move survives every
reorder.

The seed does exactly one job — choosing which concrete move `"auto"` becomes
(`choose_move(seed)` says which, so a UI can show it and a user can pin it). It
deliberately does not perturb a named move’s zoom or framing: a decision a seed
can still nudge is not a decision.

| Move                                                     | What it does                                                                                                                             |
|----------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `push_in` / `pull_out`                                   | Zoom-led, framed on the keep-region.                                                                                                     |
| `drift_left` / `drift_right` / `drift_up` / `drift_down` | Pan-led at constant zoom. **The name is the direction the camera travels** — `drift_right` slides the picture leftward across the frame. |
| `hold`                                                   | A static framing (two equal keyframes). Honours `zoom`; pass `zoom=1.0` for the untouched frame.                                         |
| `auto`                                                   | A selector over the rest, resolved by `seed`.                                                                                            |

`focus` is an explicit keep-region overriding the saliency estimate — a `Rect`, a
normalized `(x, y, w, h)` tuple, or any object with `.x/.y/.w/.h`, so a consumer
need not import burns’ rectangle type to say where to look.

## Multi-image films

`ken_burns_film` renders a sequence of `(image, path, duration_s)` panels as **one
continuous film** — a single encode pass, so there are no concatenation seams and
no per-image freeze frames at the cuts. Pass an optional pre-built audio track to
mux it in.

```python
from burns import ken_burns_film, ken_burns_path

panels = [
    ("a.jpg", ken_burns_path(1), 4.0),
    ("b.jpg", ken_burns_path(2), 4.0),
    ("c.jpg", ken_burns_path(3), 4.0),
]
ken_burns_film(panels, saveas="film.mp4", fps=30, audio_path="narration.mp3")
```

## Interop: one spec, many renderers

A `BurnsPath` serializes to a small versioned JSON document via `path.to_dict()`
(and back via `BurnsPath.from_dict(...)`). That is the wire format, and it is
already shared across two languages: [`kenburnz`](https://www.npmjs.com/package/kenburnz)
is a TypeScript port of the same `evaluate(t)` math, living in this repo’s
[`ts/`]() directory and published to npm. It is pinned to the Python side by a
shared golden-vector fixture, and adds browser-only pieces: a zero-cost CSS
transform preview, a WebCodecs `.webm` exporter, and `mountPathEntry` — a
headless component for *authoring* a path in a UI. It is young (0.0.1), and its
browser-only paths are verified locally rather than in CI. No renderer owns the
motion.

## API

| Object                                                                                                                                                                | What it does                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `Rect(x, y, w, h)`                                                                                                                                                    | A normalized viewport over the image. `.from_center_zoom`, `.clamped`, `.to_pixels`, `.zoom`, `.center`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `BurnsPath`                                                                                                                                                           | The motion spec. `.evaluate(t) -> Rect`, `.from_start_end`, `.push_in`, `.reversed`, `.to_dict` / `.from_dict`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `ken_burns_path(index, *, style="push", zoom=1.10, pan=0.03, easing="ease-in-out", output_aspect=None)`                                                               | Deterministic per-index `BurnsPath` for a sequence.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `salient_box(image, *, downscale=320, threshold_pct=72.0, trim_pct=4.0, pad=0.05, min_size=0.35)`                                                                     | Estimate the busy/detailed region of an image as a normalized `(x, y, w, h)` box.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `content_aware_path(img_w, img_h, *, subject=None, faces=(), index=0, output_aspect=None, zoom=1.3, min_zoom=1.05, keep_pad=0.18, mode="auto", easing="ease-in-out")` | Pure geometry: a `BurnsPath` that keeps a keep-region framed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `content_aware_path_for(image, *, subject=None, faces=(), faces_detector=None, index=0, output_aspect=None, **kwargs)`                                                | The same, deriving subject (`salient_box`) and faces from the image itself. An explicit `subject` replaces the saliency estimate, which is then not computed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `MOVES`                                                                                                                                                               | The named-move vocabulary — every value a stored `move` field may hold.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `resolve_move(move, *, image, aspect, zoom=1.18, focus=None, seed=0, easing="ease-in-out", on_aspect_mismatch="raise", content_box=None)`                             | Resolve an authored move (a name, or an explicit `BurnsPath`) against an image into a path. `content_box` is the part of `image` that is picture (saliency runs only there, so a composited fill is never “subject”). When the saliency box reaches three borders and covers about half the picture or more (texture everywhere, as on most photographs; `DIFFUSE_KEEP_AREA`, both ramped), the requested `zoom` is honoured around its centre instead of being capped by it (burns#21). A large subject that really does reach three borders is then cropped more at a strong zoom; pass `focus` for it, since an explicit `focus` always caps. |
| `RESOLVER_IMPL_VERSION`                                                                                                                                               | The identity of the resolver’s geometry — put it in a render cache key.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `choose_move(seed)`                                                                                                                                                   | Which concrete move `"auto"` resolves to for `seed`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `move_kind(move)`                                                                                                                                                     | How a move is grouped: `"zoom"`, `"drift"`, `"static"`, `"select"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `ken_burns_video(image, path=DEFAULT_BURNS_PATH, *, duration=2.0, fps=30, saveas=None, output_size=None, backend="pillow", ...)`                                      | Render one image into a pan/zoom mp4.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `ken_burns_film(panels, *, saveas, fps=30, audio_path=None, ...)`                                                                                                     | Render `(image, path, duration_s)` panels as one continuous film.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
<p class="epythet-aggregates">This documentation as a single file: <a href="burns.md">burns.md</a> (Markdown, for agents).</p>


# _autosummary/burns.backends.html.md

# burns.backends

Pluggable single-clip render backends — the open-closed seam.

A backend turns one `(image, BurnsPath, duration)` into one video file. They
all share the [`RenderBackend`](_autosummary/burns.backends.html.md#burns.backends.RenderBackend) call signature and live in a name-keyed
`RENDER_BACKENDS` registry, so adding a backend (an FFmpeg `zoompan`
fast-path, a future GPU path) never edits the facade — you
[`register_backend()`](_autosummary/burns.backends.html.md#burns.backends.register_backend) it and select it with `backend="…"`.

The default backend, `"pillow"`, drives a lazy `moviepy.VideoClip` whose
per-frame closure calls `burns._frame.sample_frame()`, so the whole clip is
never materialized in memory and the spec-to-pixel mapping stays in one place.
The multi-panel film renderer in [`burns.render`](_autosummary/burns.render.html.md#module-burns.render) deliberately does *not* go
through this registry — it is a single-pass encode across panels, which a
per-clip backend cannot express.

### Module Attributes

| [`DFLT_BACKEND`](_autosummary/burns.backends.html.md#burns.backends.DFLT_BACKEND)   | Still `pillow`, deliberately.   |
|-----------------------------------------------------------------|---------------------------------|

### Functions

| [`get_backend`](_autosummary/burns.backends.html.md#burns.backends.get_backend)([name])                             | Look up a registered backend by name.                                    |
|--------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| [`pillow_backend`](_autosummary/burns.backends.html.md#burns.backends.pillow_backend)(img_np, img_w, img_h, path, ...) | Default backend: lazy moviepy `VideoClip` + per-frame Pillow resampling. |
| [`register_backend`](_autosummary/burns.backends.html.md#burns.backends.register_backend)(name, backend)                 | Register a render backend under `name` (open-closed extension point).    |

### Classes

| [`RenderBackend`](_autosummary/burns.backends.html.md#burns.backends.RenderBackend)(\*args, \*\*kwargs)   | Encode one image + `BurnsPath` into a video file at `output`.   |
|--------------------------------------------------------------------------------------|-----------------------------------------------------------------|

### burns.backends.DFLT_BACKEND *= 'pillow'*

Still `pillow`, deliberately. The two backends are NOT pixel-equivalent
(measured ~47 dB, dominated by resampler choice rather than by anything
either does wrong), so flipping the default would silently change the output
of every existing caller. The speed advantage that motivates the ffmpeg path
is also still unmeasured — see thorwhalen/burns#12.

### *class* burns.backends.RenderBackend(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

Encode one image + `BurnsPath` into a video file at `output`.

Args mirror [`burns.ken_burns_video()`](_autosummary/burns.html.md#burns.ken_burns_video) after argument resolution: the
image is already a decoded `(H, W, C)` uint8 array with its pixel size,
and the output frame size `(out_w, out_h)` is already even-snapped.
Returns the written path.

### burns.backends.get_backend(name='pillow')

Look up a registered backend by name.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### Examples

```pycon
>>> get_backend() is get_backend("pillow")
True
```

### burns.backends.pillow_backend(img_np, img_w, img_h, path, , duration, fps, output, out_w, out_h, codec, audio_codec, \*\*write_kwargs)

Default backend: lazy moviepy `VideoClip` + per-frame Pillow resampling.

Quality default; jitter-free (the window is computed in floating point and
rasterized once per frame, so there is no FFmpeg `zoompan` integer-rounding
stair-step).

* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)

### burns.backends.register_backend(name, backend)

Register a render backend under `name` (open-closed extension point).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)


# _autosummary/burns.content.html.md

# burns.content

Content-aware Ken Burns: choose crop windows that keep the subject (and any
detected faces) framed, and avoid drifting over empty regions like sky.

Two layers, usable together or apart:

* [`salient_box()`](_autosummary/burns.content.html.md#burns.content.salient_box) — a **zero-dependency** (numpy + Pillow, already required)
  estimate of the busy / high-detail region of an image, so motion follows the
  subject instead of blank sky or plain ground. Good default when you have no
  detector.
* [`content_aware_path()`](_autosummary/burns.content.html.md#burns.content.content_aware_path) — the **pure geometry**: given the image size and a
  *keep-region* (the union of face boxes if any, else a subject box), build a
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) whose start/end windows keep that region fully
  > framed while zooming toward or away from it. This is the reusable, testable
  > core; it touches no pixels.

Detection is **injected**, not built in: faces come from a caller-supplied
`faces_detector` (an LLM, an ONNX/cv2 model, or manual boxes), so this module
stays dependency-light and deterministic. Boxes everywhere are normalized
`(x, y, w, h)` in `[0, 1]` with a top-left origin — the same convention as
[`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect).

The geometry [`content_aware_path()`](_autosummary/burns.content.html.md#burns.content.content_aware_path) is built from is exported rather than
inlined — [`axis_maxima()`](_autosummary/burns.content.html.md#burns.content.axis_maxima), [`keep_window()`](_autosummary/burns.content.html.md#burns.content.keep_window), [`fit_zoom()`](_autosummary/burns.content.html.md#burns.content.fit_zoom) and
[`pad_box()`](_autosummary/burns.content.html.md#burns.content.pad_box). [`burns.moves`](_autosummary/burns.moves.html.md#module-burns.moves) builds its static and drift moves out of the
same four, so “a window of this zoom, at this output aspect, centred here” has
one implementation rather than one per move.

### Module Attributes

| [`FacesDetector`](_autosummary/burns.content.html.md#burns.content.FacesDetector)   | given an image, return normalized face boxes (empty if none).                                       |
|------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
| [`DFLT_KEEP_BOX`](_autosummary/burns.content.html.md#burns.content.DFLT_KEEP_BOX)   | Where the keep-region defaults to when nothing is known about the picture: a generous centered box. |
| [`DFLT_KEEP_PAD`](_autosummary/burns.content.html.md#burns.content.DFLT_KEEP_PAD)   | Fractional breathing room around the keep-region.                                                   |

### Functions

| [`as_pil`](_autosummary/burns.content.html.md#burns.content.as_pil)(image)                                      | Open / convert `image` (path, `PIL.Image`, or ndarray) to a PIL image.                                                                                                                                                                                           |
|-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`axis_maxima`](_autosummary/burns.content.html.md#burns.content.axis_maxima)(img_w, img_h[, output_aspect])         | The largest `(w, h)` window, in image units, matching `output_aspect`.                                                                                                                                                                                           |
| [`content_aware_path`](_autosummary/burns.content.html.md#burns.content.content_aware_path)(img_w, img_h, \*[, ...])        | A `BurnsPath` that keeps the subject/faces framed while zooming toward or away from them — the content-aware counterpart of [`ken_burns_path()`](_autosummary/burns.path.html.md#burns.path.ken_burns_path).                                         |
| [`content_aware_path_for`](_autosummary/burns.content.html.md#burns.content.content_aware_path_for)(image, \*[, subject, ...])  | Convenience: derive the subject (via [`salient_box()`](_autosummary/burns.content.html.md#burns.content.salient_box)) and faces (from `faces` or `faces_detector(image)`) straight from `image`, then call [`content_aware_path()`](_autosummary/burns.content.html.md#burns.content.content_aware_path). |
| [`fit_zoom`](_autosummary/burns.content.html.md#burns.content.fit_zoom)(img_w, img_h, \*, keep[, output_aspect])  | The largest zoom at which `keep` still fits inside the window.                                                                                                                                                                                                   |
| [`keep_window`](_autosummary/burns.content.html.md#burns.content.keep_window)(img_w, img_h, \*, center, zoom[, ...]) | The window of `zoom`, at `output_aspect`, centred on `center`.                                                                                                                                                                                                   |
| [`pad_box`](_autosummary/burns.content.html.md#burns.content.pad_box)(box[, pad])                                | Grow `box` by `pad` of its own size on every side, clipped to the image.                                                                                                                                                                                         |
| [`salient_box`](_autosummary/burns.content.html.md#burns.content.salient_box)(image, \*[, downscale, ...])           | Estimate the salient (high-detail) region of `image` as a normalized box.                                                                                                                                                                                        |

### burns.content.DFLT_KEEP_BOX *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float)]* *= (0.15, 0.15, 0.7, 0.7)*

Where the keep-region defaults to when nothing is known about the picture:
a generous centered box. Not the full frame — that would give the framing
nothing to hold on to and every zoom would be capped to a standstill.

### burns.content.DFLT_KEEP_PAD *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.18*

Fractional breathing room around the keep-region. Framing a subject with its
own bounding box hard against the window edge reads as a crop, not a frame.

### burns.content.FacesDetector

given an image, return normalized face boxes (empty if none).

* **Type:**
  A detector

alias of `Callable`[[[`Any`](https://docs.python.org/3/library/typing.html#typing.Any)], [`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]]

### burns.content.as_pil(image)

Open / convert `image` (path, `PIL.Image`, or ndarray) to a PIL image.

The single entry point for “the caller gave me *some* kind of image”. Used
by every function here that needs pixels or a size, so a new accepted input
type is added in one place — and so a path is opened once per call rather
than once per function that wants it.

### burns.content.axis_maxima(img_w, img_h, output_aspect=None)

The largest `(w, h)` window, in image units, matching `output_aspect`.

At zoom `1.0` this is the window the render fills; one of the two is
always `1.0` (the axis that limits) and the other is what the output
aspect leaves of the opposite one. `output_aspect=None` means “match the
image”, so both are `1.0`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### Examples

```pycon
>>> axis_maxima(1600, 900)                    # aspect follows the image
(1.0, 1.0)
>>> [round(v, 4) for v in axis_maxima(1600, 1200, 16 / 9)]  # 4:3 -> 16:9
[1.0, 0.75]
>>> [round(v, 4) for v in axis_maxima(1080, 1920, 16 / 9)]  # portrait
[1.0, 0.3164]
```

### burns.content.content_aware_path(img_w, img_h, , subject=None, faces=(), index=0, output_aspect=None, zoom=1.3, min_zoom=1.05, keep_pad=0.18, mode='auto', easing='ease-in-out')

A `BurnsPath` that keeps the subject/faces framed while zooming
toward or away from them — the content-aware counterpart of
[`ken_burns_path()`](_autosummary/burns.path.html.md#burns.path.ken_burns_path).

The *keep-region* is the union of `faces` if any, else `subject` (else a
centered default). Both start and end crop windows are built to contain that
region fully, centered on it; the end zoom is capped so the region never
leaves the frame. Windows carry the output pixel-aspect so the render’s
cover-crop is a no-op and what you frame is what shows.

* **Parameters:**
  * **img_w** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – source image pixel size (the subject box lives in this space).
  * **img_h** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – source image pixel size (the subject box lives in this space).
  * **subject** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – normalized `(x,y,w,h)` of the main subject (e.g. from
    [`salient_box()`](_autosummary/burns.content.html.md#burns.content.salient_box)). Optional.
  * **faces** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – normalized face boxes; when present they are the keep-region and
    take priority over `subject`.
  * **index** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – sequence position; in `mode="auto"` odd indices push in, even
    pull out, giving a sequence rhythm (mirrors `ken_burns_path`).
  * **output_aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the render’s `width/height`; defaults to the image’s.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – target end magnification (capped to keep the region framed).
  * **min_zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – floor magnification for the non-zoomed end.
  * **keep_pad** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – fractional padding around the keep-region.
  * **mode** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `"auto"` (alternate by index) · `"in"` (always toward) ·
    `"out"` (always away).
* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> p = content_aware_path(1600, 900, subject=(0.6, 0.55, 0.2, 0.25), index=1)
>>> r0, r1 = p.evaluate(0.0), p.evaluate(1.0)
>>> r1.zoom >= r0.zoom            # index 1 -> push in
True
>>> content_aware_path(100, 100, subject=(0,0,1,1)) == content_aware_path(100, 100, subject=(0,0,1,1))
True
```

### burns.content.content_aware_path_for(image, , subject=None, faces=(), faces_detector=None, index=0, output_aspect=None, \*\*kwargs)

Convenience: derive the subject (via [`salient_box()`](_autosummary/burns.content.html.md#burns.content.salient_box)) and faces (from
`faces` or `faces_detector(image)`) straight from `image`, then call
[`content_aware_path()`](_autosummary/burns.content.html.md#burns.content.content_aware_path). `image` may be a path, PIL image, or ndarray.

Keeps burns dependency-light: pass an LLM/cv2/manual `faces_detector` when
you want face-aware framing; omit it for saliency-only (sky-avoiding) motion.

`subject` is an explicit keep-region that **replaces** the saliency
estimate — for a caller who already knows what the picture is about (a
hand-drawn crop, an upstream detector, a stored authoring decision). It is a
named parameter rather than one more key in `**kwargs` because those are
forwarded to [`content_aware_path()`](_autosummary/burns.content.html.md#burns.content.content_aware_path), which already takes `subject` —
so passing it that way raised `TypeError: got multiple values` two frames
down. When given, [`salient_box()`](_autosummary/burns.content.html.md#burns.content.salient_box) is **not called**: the override is
total, and computing an estimate only to discard it is how a partial
override quietly lets saliency back in.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> import numpy as np
>>> a = np.zeros((600, 800, 3), dtype='uint8'); a[80:200, 60:200] = 220
>>> p = content_aware_path_for(a, subject=(0.7, 0.7, 0.2, 0.2), index=1)
>>> r = p.evaluate(1.0)                     # framed on the override,
>>> r.x + r.w / 2 > 0.5 and r.y + r.h / 2 > 0.5   # not the bright corner
True
```

### burns.content.fit_zoom(img_w, img_h, , keep, output_aspect=None)

The largest zoom at which `keep` still fits inside the window.

`keep` is taken as given — pad it with [`pad_box()`](_autosummary/burns.content.html.md#burns.content.pad_box) first if you want
the subject framed rather than touching the edges.

* **Return type:**
  [`float`](https://docs.python.org/3/builtins/functions.html#float)

### Examples

```pycon
>>> round(fit_zoom(1000, 1000, keep=(0.25, 0.25, 0.5, 0.5)), 4)
2.0
>>> round(fit_zoom(1000, 1000, keep=(0.0, 0.0, 1.0, 1.0)), 4)  # fills it
1.0
```

### burns.content.keep_window(img_w, img_h, , center, zoom, output_aspect=None)

The window of `zoom`, at `output_aspect`, centred on `center`.

Clamped inside the image by sliding, never by resizing (see
[`clamped()`](_autosummary/burns.rect.html.md#burns.rect.Rect.clamped)) — shrinking would change the window’s
aspect and make the rendered frame breathe.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> keep_window(1000, 1000, center=(0.5, 0.5), zoom=2.0)
Rect(x=0.25, y=0.25, w=0.5, h=0.5)
>>> keep_window(1000, 1000, center=(0.95, 0.5), zoom=2.0)  # rides the wall
Rect(x=0.5, y=0.25, w=0.5, h=0.5)
```

### burns.content.pad_box(box, pad=0.18)

Grow `box` by `pad` of its own size on every side, clipped to the image.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### Examples

```pycon
>>> [round(v, 4) for v in pad_box((0.4, 0.4, 0.2, 0.2), 0.25)]
[0.35, 0.35, 0.3, 0.3]
>>> pad_box((0.0, 0.0, 1.0, 1.0), 0.5)  # already the whole image
(0.0, 0.0, 1.0, 1.0)
```

### burns.content.salient_box(image, , downscale=320, threshold_pct=72.0, trim_pct=4.0, pad=0.05, min_size=0.35)

Estimate the salient (high-detail) region of `image` as a normalized box.

Uses gradient magnitude: flat regions (sky, walls, water) have low gradient
and fall away, so the bounding box of the high-gradient pixels tracks the
subject. Robust to outliers via percentile trimming. Falls back to a
centered box when the image is too uniform to decide.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### Examples

```pycon
>>> import numpy as np
>>> a = np.zeros((100, 100), dtype='uint8'); a[60:90, 40:70] = 255
>>> x, y, w, h = salient_box(a, min_size=0.0, pad=0.0)
>>> 0.3 < x < 0.45 and 0.55 < y < 0.65   # box around the bright square
True
```


# _autosummary/burns.easing.html.md

# burns.easing

Timing functions (easing) for Ken Burns motion.

Easing maps normalized clock time `t in [0, 1]` to normalized progress
`[0, 1]`. It is composed *over* the geometry: a path’s viewport at clock
time `t` is `path.evaluate_geometry(easing(t))` — the After-Effects split
of *what shape the motion traces* (the path) from *how fast it moves along it*
(the timing). Keeping them orthogonal is the single most useful design lesson
from the NLE prior art.

The public currency is the **CSS timing-function string** — `"linear"`,
`"ease"`, `"ease-in"`, `"ease-out"`, `"ease-in-out"`, or an explicit
`"cubic-bezier(x1, y1, x2, y2)"` — because that vocabulary is understood
verbatim by CSS, Remotion’s `Easing.bezier`, and GSAP’s `CustomEase` alike.
The cinematic default is `"ease-in-out"` (Final Cut Pro’s Ken Burns default),
**not** linear.

[`parse_easing()`](_autosummary/burns.easing.html.md#burns.easing.parse_easing) also accepts a 4-tuple of bezier control values or any
callable `float -> float`, so power users can inject an arbitrary curve.

### Functions

| [`cubic_bezier`](_autosummary/burns.easing.html.md#burns.easing.cubic_bezier)(x1, y1, x2, y2)   | A CSS-style cubic-bezier easing `f: [0, 1] -> [0, 1]`.      |
|---------------------------------------------------------------------------------|-------------------------------------------------------------|
| [`parse_easing`](_autosummary/burns.easing.html.md#burns.easing.parse_easing)([spec])           | Resolve an easing spec to a callable `f: [0, 1] -> [0, 1]`. |

### burns.easing.cubic_bezier(x1, y1, x2, y2)

A CSS-style cubic-bezier easing `f: [0, 1] -> [0, 1]`.

The curve runs from `(0, 0)` to `(1, 1)` with control points
`(x1, y1)` and `(x2, y2)`. Evaluation inverts `x(t)` for the curve
parameter (bisection — robust for any monotone-x curve) then returns
`y(t)`.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### Examples

```pycon
>>> linear = cubic_bezier(0.0, 0.0, 1.0, 1.0)
>>> round(linear(0.5), 6)
0.5
>>> round(cubic_bezier(0.42, 0.0, 0.58, 1.0)(0.5), 6)  # ease-in-out
0.5
```

### burns.easing.parse_easing(spec='ease-in-out')

Resolve an easing spec to a callable `f: [0, 1] -> [0, 1]`.

Accepts:

> - a CSS name (`"linear"`, `"ease"`, `"ease-in"`, `"ease-out"`,
>   `"ease-in-out"`);
> - a CSS `"cubic-bezier(x1, y1, x2, y2)"` string;
> - a 4-element sequence `(x1, y1, x2, y2)`;
> - any callable, returned unchanged.

### Examples

```pycon
>>> parse_easing("linear")(0.3)
0.3
>>> round(parse_easing("cubic-bezier(0,0,1,1)")(0.7), 6)
0.7
>>> parse_easing(lambda t: t * t)(0.5)
0.25
```

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)]


# _autosummary/burns.html.md

# burns

burns — Ken Burns pan/zoom video effects.

Turn a still image (or a sequence of stills) into a cinematic pan/zoom film,
driven by one **render-agnostic motion spec** so the same path feeds the Python
renderer here and the in-browser TypeScript port (`kenburnz`, in `ts/`).

The core abstraction is a pure, time-parameterized spec:

- [`Rect`](_autosummary/burns.html.md#burns.Rect) — a normalized `(x, y, w, h)` viewport over the image
  (top-left origin, window-fraction zoom).
- [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) — keyframes + easing, with `evaluate(t) -> Rect` (pure,
  deterministic, frame-count-free) and JSON `to_dict` / `from_dict`.
- [`ken_burns_path()`](_autosummary/burns.html.md#burns.ken_burns_path) — build a cohesive, deterministic path per sequence
  index from a little intent (style / zoom / pan / easing).
- [`content_aware_path_for()`](_autosummary/burns.html.md#burns.content_aware_path_for) — the content-aware counterpart: keep the
  subject (via [`salient_box()`](_autosummary/burns.html.md#burns.salient_box)) and any injected face boxes framed.
- `MOVES` + [`resolve_move()`](_autosummary/burns.html.md#burns.resolve_move) — the named-move vocabulary, for callers
  that want to *store* a camera move as an authored intent and resolve it
  against whatever still is in the slot at render time. A caller that caches
  the render puts `RESOLVER_IMPL_VERSION` in its key, since the pixels an
  unchanged intent becomes are decided here.

Two renderers consume a path plus a render-time `duration`:

- [`ken_burns_video()`](_autosummary/burns.html.md#burns.ken_burns_video) — one image -> one mp4 (pluggable backend).
- [`ken_burns_film()`](_autosummary/burns.html.md#burns.ken_burns_film) — a sequence of `(image, path, duration)` panels ->
  one continuous mp4 (single encode pass, no seams), with optional audio.

Quickstart:

```pycon
>>> from burns import ken_burns_video, ken_burns_path
>>> ken_burns_video("photo.jpg")  # 2s push-in
>>> ken_burns_video(  # path per sequence index
...     "photo.jpg", ken_burns_path(1, style="push"), duration=5.0
... )
```

### Functions

| [`ken_burns_path`](_autosummary/burns.html.md#burns.ken_burns_path)(index, \*[, style, zoom, pan, ...])   | A deterministic [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) for the `index`-th image of a sequence.                                                                                                                                                      |
|-------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`content_aware_path`](_autosummary/burns.html.md#burns.content_aware_path)(img_w, img_h, \*[, ...])          | A [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) that keeps the subject/faces framed while zooming toward or away from them — the content-aware counterpart of [`ken_burns_path()`](_autosummary/burns.path.html.md#burns.path.ken_burns_path). |
| [`content_aware_path_for`](_autosummary/burns.html.md#burns.content_aware_path_for)(image, \*[, subject, ...])    | Convenience: derive the subject (via [`salient_box()`](_autosummary/burns.html.md#burns.salient_box)) and faces (from `faces` or `faces_detector(image)`) straight from `image`, then call [`content_aware_path()`](_autosummary/burns.html.md#burns.content_aware_path).        |
| [`salient_box`](_autosummary/burns.html.md#burns.salient_box)(image, \*[, downscale, ...])             | Estimate the salient (high-detail) region of `image` as a normalized box.                                                                                                                                                                                               |
| [`choose_move`](_autosummary/burns.html.md#burns.choose_move)(seed)                                    | Which concrete move `"auto"` resolves to for `seed`.                                                                                                                                                                                                                    |
| [`move_kind`](_autosummary/burns.html.md#burns.move_kind)(move)                                      | How `move` is grouped: `"zoom"`, `"drift"`, `"static"`, `"select"`.                                                                                                                                                                                                     |
| [`resolve_move`](_autosummary/burns.html.md#burns.resolve_move)(move, \*, image, aspect[, zoom, ...])   | Resolve an authored camera intent against `image` into a path.                                                                                                                                                                                                          |
| [`ken_burns_video`](_autosummary/burns.html.md#burns.ken_burns_video)(image[, path, duration, ...])        | Render one image into a pan/zoom video from a [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath).                                                                                                                                                               |
| [`ken_burns_film`](_autosummary/burns.html.md#burns.ken_burns_film)(panels, \*, saveas[, fps, ...])       | Render an N-panel Ken Burns film in a single pass.                                                                                                                                                                                                                      |
| [`parse_easing`](_autosummary/burns.html.md#burns.parse_easing)([spec])                                 | Resolve an easing spec to a callable `f: [0, 1] -> [0, 1]`.                                                                                                                                                                                                             |
| [`cubic_bezier`](_autosummary/burns.html.md#burns.cubic_bezier)(x1, y1, x2, y2)                         | A CSS-style cubic-bezier easing `f: [0, 1] -> [0, 1]`.                                                                                                                                                                                                                  |
| [`register_backend`](_autosummary/burns.html.md#burns.register_backend)(name, backend)                      | Register a render backend under `name` (open-closed extension point).                                                                                                                                                                                                   |
| [`get_backend`](_autosummary/burns.html.md#burns.get_backend)([name])                                  | Look up a registered backend by name.                                                                                                                                                                                                                                   |

### Classes

| [`Rect`](_autosummary/burns.html.md#burns.Rect)(x, y, w, h)                            | A normalized `(x, y, w, h)` viewport over a source image.                                                    |
|----------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|
| [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath)(keyframes[, easing, interp, ...]) | A time-parameterized pan/zoom motion over a still image.                                                     |
| [`RenderBackend`](_autosummary/burns.html.md#burns.RenderBackend)(\*args, \*\*kwargs)           | Encode one image + [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) into a video file at `output`. |

### Exceptions

| [`MoveError`](_autosummary/burns.html.md#burns.MoveError)   | A move that cannot be resolved into a path.   |
|--------------------------------------------------------------|-----------------------------------------------|

### *class* burns.BurnsPath(keyframes, easing='ease-in-out', interp='linear', output_aspect=None, version=1)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A time-parameterized pan/zoom motion over a still image.

Construct it directly from keyframes, or via [`from_start_end()`](_autosummary/burns.html.md#burns.BurnsPath.from_start_end) /
[`push_in()`](_autosummary/burns.html.md#burns.BurnsPath.push_in) for the common cases, or via [`ken_burns_path()`](_autosummary/burns.html.md#burns.ken_burns_path) for
deterministic per-index motion across a sequence.

* **Parameters:**
  * **keyframes** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)], [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]) – a sequence of `(t, Rect)` waypoints, `t in [0, 1]`,
    strictly increasing in `t`. Must have at least one entry; the
    first `t` should be `0.0` and the last `1.0` for the whole
    clock to be covered (out-of-range `t` clamps to the ends).
  * **easing** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – a CSS timing-function spec (name / `cubic-bezier(...)` /
    4-tuple) or a callable `[0,1] -> [0,1]`. Default `"ease-in-out"`.
  * **interp** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – geometry interpolation between keyframes. Only `"linear"` is
    implemented; the field exists so the spec can carry richer schemes
    (`"catmull-rom"`, `"bezier"`) without a format change.
  * **output_aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the aspect ratio (`width / height`) the render should
    fill. `None` means “match the source image”.
  * **version** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – spec schema version (for forward-compatible serialization).

### Examples

```pycon
>>> p = BurnsPath.from_start_end(
...     Rect(0, 0, 1, 1), Rect.from_center_zoom(0.5, 0.5, 1.3)
... )
>>> p.evaluate(0.0)
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> round(p.evaluate(1.0).zoom, 4)
1.3
>>> p.duration_keyframes  # number of waypoints
2
```

#### *property* duration_keyframes *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Number of keyframe waypoints (`2` for the canonical Start/End).

#### evaluate(t)

The viewport [`Rect`](_autosummary/burns.html.md#burns.Rect) at normalized clock time `t in [0, 1]`.

Applies easing to the clock, then linearly interpolates the geometry at
the eased progress. Pure and deterministic: no image, no I/O, no frame
count. `t` outside `[0, 1]` clamps to the nearest end.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> p = BurnsPath.from_start_end(
...     Rect(0, 0, 1, 1), Rect(0, 0, 0.5, 0.5), easing="linear"
... )
>>> p.evaluate(0.5)
Rect(x=0.0, y=0.0, w=0.75, h=0.75)
```

#### *classmethod* from_dict(d)

Rebuild a [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) from [`to_dict()`](_autosummary/burns.html.md#burns.BurnsPath.to_dict) output.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> p = BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
>>> BurnsPath.from_dict(p.to_dict()) == p
True
```

#### *classmethod* from_start_end(start, end, , easing='ease-in-out', output_aspect=None)

The canonical two-rectangle Ken Burns case (Start frame -> End frame).

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
...
BurnsPath(keyframes=((0.0, Rect(...)), (1.0, Rect(...))), ...)
```

#### *classmethod* push_in(zoom=1.3, , to=(0.5, 0.5), easing='ease-in-out', output_aspect=None)

The 90%-case constructor: a slow push from the full image toward
`to` at `zoom`.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> round(BurnsPath.push_in().evaluate(1.0).zoom, 4)
1.3
```

#### reversed()

Swap start and end (the NLE “Swap Start and End Areas” button).

Mirrors every keyframe’s time about `0.5` and re-sorts, so the motion
plays back-to-front. Easing/interp/output_aspect are preserved.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> p = BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
>>> p.reversed().evaluate(0.0)
Rect(x=0.0, y=0.0, w=0.5, h=0.5)
```

#### to_dict()

Serialize to the versioned JSON wire format (the cross-language SSOT).

Easing is stored as the original CSS string / 4-tuple. A callable easing
cannot be serialized and raises — use a CSS spec for paths that travel.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### Examples

```pycon
>>> p = BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
>>> d = p.to_dict()
>>> d["version"], d["easing"], len(d["keyframes"])
(1, 'ease-in-out', 2)
```

### *exception* burns.MoveError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

A move that cannot be resolved into a path.

A plain [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError) subclass, so a consumer that already catches
`ValueError` around its authoring layer keeps working, and so nothing
downstream needs to import [`burns.moves`](_autosummary/burns.moves.html.md#module-burns.moves) to handle it.

### *class* burns.Rect(x, y, w, h)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A normalized `(x, y, w, h)` viewport over a source image.

All four components are fractions of the image in `[0, 1]` with a
top-left origin. `(x, y)` is the top-left corner of the window; `w` and
`h` are its width and height. `Rect(0, 0, 1, 1)` is the whole image.

### Examples

```pycon
>>> full = Rect(0.0, 0.0, 1.0, 1.0)
>>> full.center
(0.5, 0.5)
>>> full.zoom
1.0
>>> Rect.from_center_zoom(0.5, 0.5, 2.0)
Rect(x=0.25, y=0.25, w=0.5, h=0.5)
```

#### *property* aspect *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Aspect ratio of the window *in normalized image units* (`w / h`).

Note this is not the rendered-pixel aspect ratio unless the image is
square — the pixel AR is `(w * img_w) / (h * img_h)`. The render path
reconciles the window with the desired output AR via a cover-crop.

#### *property* center *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float)]*

The window’s center `(cx, cy)` in `[0, 1]` image units.

#### clamped()

Slide the window inside the image **without resizing it**.

Clamps the top-left corner so the window “rides the wall” at an edge
rather than shrinking — shrinking the box would change its aspect ratio
and make the rendered frame breathe/stretch. Windows larger than the
image in a dimension are centered in that dimension.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> Rect(0.8, 0.0, 0.5, 0.5).clamped()
Rect(x=0.5, y=0.0, w=0.5, h=0.5)
>>> Rect(-0.2, 0.3, 0.4, 0.4).clamped()
Rect(x=0.0, y=0.3, w=0.4, h=0.4)
```

#### *classmethod* from_center_zoom(cx, cy, zoom=1.0, , aspect=1.0)

Build a rect from a pan center, a zoom, and a window aspect ratio.

This is the bridge from the older center+magnification mental model to
the `(x, y, w, h)` representation. `zoom` is window-fraction
magnification (`1.0` = full image, `> 1.0` = zoomed in). `aspect`
is the window’s normalized `w / h`; the default `1.0` yields an
isotropic window (`w == h`) whose rendered AR equals the source
image’s — reproducing the legacy behavior exactly. The result is
clamped to stay inside the image.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> Rect.from_center_zoom(0.5, 0.5, 1.0)
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> Rect.from_center_zoom(0.75, 0.5, 2.0)  # off-center zoom-in
Rect(x=0.5, y=0.25, w=0.5, h=0.5)
```

#### is_contained()

True when the window lies wholly inside the image.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### Examples

```pycon
>>> Rect(0.1, 0.1, 0.5, 0.5).is_contained()
True
>>> Rect(0.8, 0.0, 0.5, 0.5).is_contained()
False
```

#### lerp(other, t)

Linearly interpolate toward `other` by `t` in `[0, 1]`.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> Rect(0, 0, 1, 1).lerp(Rect(0.25, 0.25, 0.5, 0.5), 0.5)
Rect(x=0.125, y=0.125, w=0.75, h=0.75)
```

#### to_pixels(img_w, img_h)

Map the (clamped) window to an integer pixel box `(x0, y0, x1, y1)`.

Clamps first so the box is always inside the raster, then rounds (not
truncates) for symmetric integer conversion. The returned box is a
half-open crop region suitable for `ndarray[y0:y1, x0:x1]`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### Examples

```pycon
>>> Rect(0.0, 0.0, 1.0, 1.0).to_pixels(100, 80)
(0, 0, 100, 80)
>>> Rect.from_center_zoom(0.5, 0.5, 2.0).to_pixels(100, 80)
(25, 20, 75, 60)
```

#### *property* zoom *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Magnification implied by the window, `1 / max(w, h)`.

`1.0` = the full image; `> 1.0` = zoomed in. This is the derived
value an FFmpeg `zoompan` backend consumes; the window-fraction
`(w, h)` is the authoritative, user-facing representation.

### *class* burns.RenderBackend(\*args, \*\*kwargs)

Bases: [`Protocol`](https://docs.python.org/3/library/typing.html#typing.Protocol)

Encode one image + [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) into a video file at `output`.

Args mirror [`burns.ken_burns_video()`](_autosummary/burns.html.md#burns.ken_burns_video) after argument resolution: the
image is already a decoded `(H, W, C)` uint8 array with its pixel size,
and the output frame size `(out_w, out_h)` is already even-snapped.
Returns the written path.

### burns.choose_move(seed)

Which concrete move `"auto"` resolves to for `seed`.

Public because “auto” is a decision a person may want to *see* (a UI
showing `auto → drift_left`) and to *pin* (liking what auto chose and
storing that name instead, so it survives a change to the weights).

A pure function of `seed` alone — not of the image, and not of any
position in a sequence. That is what makes a panel’s move survive both a
reorder and a swapped still.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### Examples

```pycon
>>> choose_move(7) == choose_move(7)
True
>>> choose_move(7) in MOVES and choose_move(7) != 'auto'
True
>>> len({choose_move(s) for s in range(60)}) > 1   # not one move forever
True
```

### burns.content_aware_path(img_w, img_h, , subject=None, faces=(), index=0, output_aspect=None, zoom=1.3, min_zoom=1.05, keep_pad=0.18, mode='auto', easing='ease-in-out')

A [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) that keeps the subject/faces framed while zooming
toward or away from them — the content-aware counterpart of
[`ken_burns_path()`](_autosummary/burns.path.html.md#burns.path.ken_burns_path).

The *keep-region* is the union of `faces` if any, else `subject` (else a
centered default). Both start and end crop windows are built to contain that
region fully, centered on it; the end zoom is capped so the region never
leaves the frame. Windows carry the output pixel-aspect so the render’s
cover-crop is a no-op and what you frame is what shows.

* **Parameters:**
  * **img_w** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – source image pixel size (the subject box lives in this space).
  * **img_h** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – source image pixel size (the subject box lives in this space).
  * **subject** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – normalized `(x,y,w,h)` of the main subject (e.g. from
    [`salient_box()`](_autosummary/burns.html.md#burns.salient_box)). Optional.
  * **faces** ([`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – normalized face boxes; when present they are the keep-region and
    take priority over `subject`.
  * **index** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – sequence position; in `mode="auto"` odd indices push in, even
    pull out, giving a sequence rhythm (mirrors `ken_burns_path`).
  * **output_aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the render’s `width/height`; defaults to the image’s.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – target end magnification (capped to keep the region framed).
  * **min_zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – floor magnification for the non-zoomed end.
  * **keep_pad** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – fractional padding around the keep-region.
  * **mode** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `"auto"` (alternate by index) · `"in"` (always toward) ·
    `"out"` (always away).
* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> p = content_aware_path(1600, 900, subject=(0.6, 0.55, 0.2, 0.25), index=1)
>>> r0, r1 = p.evaluate(0.0), p.evaluate(1.0)
>>> r1.zoom >= r0.zoom            # index 1 -> push in
True
>>> content_aware_path(100, 100, subject=(0,0,1,1)) == content_aware_path(100, 100, subject=(0,0,1,1))
True
```

### burns.content_aware_path_for(image, , subject=None, faces=(), faces_detector=None, index=0, output_aspect=None, \*\*kwargs)

Convenience: derive the subject (via [`salient_box()`](_autosummary/burns.html.md#burns.salient_box)) and faces (from
`faces` or `faces_detector(image)`) straight from `image`, then call
[`content_aware_path()`](_autosummary/burns.html.md#burns.content_aware_path). `image` may be a path, PIL image, or ndarray.

Keeps burns dependency-light: pass an LLM/cv2/manual `faces_detector` when
you want face-aware framing; omit it for saliency-only (sky-avoiding) motion.

`subject` is an explicit keep-region that **replaces** the saliency
estimate — for a caller who already knows what the picture is about (a
hand-drawn crop, an upstream detector, a stored authoring decision). It is a
named parameter rather than one more key in `**kwargs` because those are
forwarded to [`content_aware_path()`](_autosummary/burns.html.md#burns.content_aware_path), which already takes `subject` —
so passing it that way raised `TypeError: got multiple values` two frames
down. When given, [`salient_box()`](_autosummary/burns.html.md#burns.salient_box) is **not called**: the override is
total, and computing an estimate only to discard it is how a partial
override quietly lets saliency back in.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> import numpy as np
>>> a = np.zeros((600, 800, 3), dtype='uint8'); a[80:200, 60:200] = 220
>>> p = content_aware_path_for(a, subject=(0.7, 0.7, 0.2, 0.2), index=1)
>>> r = p.evaluate(1.0)                     # framed on the override,
>>> r.x + r.w / 2 > 0.5 and r.y + r.h / 2 > 0.5   # not the bright corner
True
```

### burns.cubic_bezier(x1, y1, x2, y2)

A CSS-style cubic-bezier easing `f: [0, 1] -> [0, 1]`.

The curve runs from `(0, 0)` to `(1, 1)` with control points
`(x1, y1)` and `(x2, y2)`. Evaluation inverts `x(t)` for the curve
parameter (bisection — robust for any monotone-x curve) then returns
`y(t)`.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### Examples

```pycon
>>> linear = cubic_bezier(0.0, 0.0, 1.0, 1.0)
>>> round(linear(0.5), 6)
0.5
>>> round(cubic_bezier(0.42, 0.0, 0.58, 1.0)(0.5), 6)  # ease-in-out
0.5
```

### burns.get_backend(name='pillow')

Look up a registered backend by name.

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis), [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)]

### Examples

```pycon
>>> get_backend() is get_backend("pillow")
True
```

### burns.ken_burns_film(panels, , saveas, fps=30, audio_path=None, codec='libx264', audio_codec='aac', \*\*write_kwargs)

Render an N-panel Ken Burns film in a single pass.

Each panel is an `(image, path, duration_s)` triple
(`burns.PanelInput`): `image` is a path / `PIL.Image` /
`np.ndarray`, `path` a [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath), `duration_s` how long the
panel occupies the film. Panels play back-to-back; the camera cuts at panel
boundaries but motion never pauses on a static frame within a panel.

A single `VideoClip` (rather than per-panel render + concatenate) avoids
the concat re-encode seam and per-panel tail freeze, and keeps frame
generation lazy via one global `make_frame(t)` closure.

* **Parameters:**
  * **panels** – iterable of `(image, path, duration_s)` triples.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – output mp4 path (required — a film has no single source image).
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – frame rate.
  * **audio_path** – optional pre-built track (already matching the film
    duration); muxed in when supplied. Per-panel audio assembly is the
    caller’s job — the renderer stays pure visual.
  * **codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **audio_codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **\*\*write_kwargs** – forwarded to `write_videofile`.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  The written mp4 [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path).

### burns.ken_burns_path(index, , style='push', zoom=1.1, pan=0.03, easing='ease-in-out', output_aspect=None)

A deterministic [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) for the `index`-th image of a sequence.

Maps intent to geometry so a sequence of images gets cohesive, non-repetitive
motion without hand-authoring rectangles. Per-index deterministic: identical
args always return an identical path. Duration is *not* part of the path — it
is a render-time parameter (pass it to [`burns.ken_burns_video()`](_autosummary/burns.html.md#burns.ken_burns_video) /
[`burns.ken_burns_film()`](_autosummary/burns.html.md#burns.ken_burns_film)).

Two styles:

- `style="push"` (default) — the “cinematic push”: one slow zoom toward an
  off-center focal point. **Odd indices push in, even indices pull out**, so a
  sequence has visual rhythm without changing direction *within* a shot. The
  focal direction rotates through compass octants per index.
- `style="drift"` — pure horizontal pan at a constant zoom, alternating
  direction per index (odd drifts right, even left). `zoom` is ignored;
  drift derives its own zoom from `pan` so the slide is visible.

* **Parameters:**
  * **index** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – the image’s 1-based position in the sequence.
  * **style** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `"push"` (default) or `"drift"`.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the zoomed-end magnification (> 1.0) for `"push"`.
  * **pan** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – focal-point offset (`"push"`) or total horizontal travel
    (`"drift"`), in `[0, 1]` image units.
  * **easing** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – CSS timing function or callable. Default `"ease-in-out"` — the
    cinematic slow-in/slow-out. Pass `"linear"` for constant velocity.
  * **output_aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – aspect ratio the render should fill (`None` = match image).
* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)
* **Returns:**
  A [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath) (two keyframes — Start and End).

### Examples

```pycon
>>> ken_burns_path(1).evaluate(0.0)  # odd: push in, starts full
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> ken_burns_path(2).evaluate(1.0)  # even: pull out, ends full
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> ken_burns_path(3) == ken_burns_path(3)  # deterministic
True
```

### burns.ken_burns_video(image, path=BurnsPath(keyframes=((0.0, Rect(x=0.0, y=0.0, w=1.0, h=1.0)), (1.0, Rect(x=0.11538461538461542, y=0.11538461538461542, w=0.7692307692307692, h=0.7692307692307692))), easing='ease-in-out', interp='linear', output_aspect=None, version=1), , duration=2.0, fps=30, saveas=None, output_size=None, backend='pillow', codec='libx264', audio_codec='aac', \*\*write_kwargs)

Render one image into a pan/zoom video from a [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath).

* **Parameters:**
  * **image** – path / `PIL.Image` / `np.ndarray`.
  * **path** ([`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)) – the motion spec. Default is a 2-second standard push-in.
  * **duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – clip length in seconds (the path’s clock is normalized, so
    duration is supplied here, not baked into the path).
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – frames per second.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – output path. Default: the source image’s name with
    `_kenburns` appended (auto-uniquified so an existing file is not
    overwritten). Required-ish when `image` has no source path —
    falls back to a temp file.
  * **output_size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – explicit `(width, height)` (even-snapped). If omitted and
    `path.output_aspect` is set, the size is derived from it; otherwise
    the source image’s size is used.
  * **backend** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – registered render backend name (default `"pillow"`).
  * **codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **audio_codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **\*\*write_kwargs** – forwarded to `write_videofile`.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  The output [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path).

### Examples

```pycon
>>> ken_burns_video("photo.jpg")  # 2s push-in
>>> from burns import ken_burns_path
>>> ken_burns_video(
...     "photo.jpg", ken_burns_path(1), duration=5.0, saveas="out.mp4"
... )
```

### burns.move_kind(move)

How `move` is grouped: `"zoom"`, `"drift"`, `"static"`, `"select"`.

For a UI that wants a compass of directions separately from the rest, read
off the same table [`resolve_move()`](_autosummary/burns.html.md#burns.resolve_move) dispatches on.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### Examples

```pycon
>>> move_kind("drift_left"), move_kind("hold"), move_kind("auto")
('drift', 'static', 'select')
>>> sorted(m for m in MOVES if move_kind(m) == 'drift')
['drift_down', 'drift_left', 'drift_right', 'drift_up']
```

### burns.parse_easing(spec='ease-in-out')

Resolve an easing spec to a callable `f: [0, 1] -> [0, 1]`.

Accepts:

> - a CSS name (`"linear"`, `"ease"`, `"ease-in"`, `"ease-out"`,
>   `"ease-in-out"`);
> - a CSS `"cubic-bezier(x1, y1, x2, y2)"` string;
> - a 4-element sequence `(x1, y1, x2, y2)`;
> - any callable, returned unchanged.

### Examples

```pycon
>>> parse_easing("linear")(0.3)
0.3
>>> round(parse_easing("cubic-bezier(0,0,1,1)")(0.7), 6)
0.7
>>> parse_easing(lambda t: t * t)(0.5)
0.25
```

* **Return type:**
  [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### burns.register_backend(name, backend)

Register a render backend under `name` (open-closed extension point).

* **Return type:**
  [`None`](https://docs.python.org/3/builtins/constants.html#None)

### burns.resolve_move(move, , image, aspect, zoom=1.18, focus=None, seed=0, easing='ease-in-out', on_aspect_mismatch='raise', content_box=None)

Resolve an authored camera intent against `image` into a path.

* **Parameters:**
  * **move** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath), [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]) – a name from `MOVES`, or an explicit
    [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) / its `to_dict()` payload. The two
    are the two front doors on this one path: a stored panel that
    carries both a named move and a hand-corrected override passes the
    override here and the choice is made in one place.
  * **image** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – the still the move is framed against — a path, `PIL.Image` or
    ndarray. **Required, and read every time**: the framing depends on
    what is in the picture, so swapping the still must re-frame. Do not
    cache the returned path against the intent.
  * **aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the delivered `width / height`. `None` means “match the
    image”. Required rather than defaulted, because a cut has a
    delivery size and quietly framing for the image’s aspect instead is
    a cover-crop nobody asked for.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – 

    the end magnification. A pure function of the arguments — never
    jittered by `seed` or by a position — but \*\*bounded at both
    ends\*\*, so the number you pass is a request:
    * capped so the padded keep-region stays framed. A `focus`
      filling the frame therefore yields a nearly static move; the fix
      is a tighter `focus`, not a bigger `zoom`. A keep-region
      that *saliency* found covering `DIFFUSE_KEEP_AREA` or more
      of the picture is only a centre hint and does not cap: on a
      detailed photograph it covers nearly everything, and capping on
      it made every zoom render the same ~1.07.
    * floored. `push_in` / `pull_out` inherit
      `content_aware_path`’s `min_zoom + 0.02` (about 1.07), so a
      barely-there 1.02 push renders as 1.07; `hold` has no such floor
      and honours 1.02 exactly. A drift is floored to whatever leaves
      `DRIFT_MIN_ROOM` of travel.
  * **focus** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – an explicit keep-region overriding the saliency estimate. A
    [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect), a normalized `(x, y, w, h)` tuple, or
    any object with `.x/.y/.w/.h`. When given,
    [`salient_box()`](_autosummary/burns.content.html.md#burns.content.salient_box) is not called at all.
  * **seed** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – chooses which move `"auto"` becomes, and nothing else. Mint it
    once per panel and store it; never derive it from a position, which
    is the defect this parameter exists to remove.
  * **easing** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – CSS timing function or callable. A callable is fine here but
    cannot be serialized — see [`BurnsPath.to_dict()`](_autosummary/burns.html.md#burns.BurnsPath.to_dict). Applies to a
    named move; an explicit path carries its own.
  * **content_box** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – the part of `image` that is picture, as a normalized
    `(x, y, w, h)` (same forms as `focus`). Saliency runs inside it,
    so the blurred fill or bars a caller composited around a still do
    not read as subject. Ignored when `focus` is given.
  * **on_aspect_mismatch** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – what to do when an explicit path was authored for a
    different `output_aspect` than the one being rendered.
    `"raise"` (default) refuses, because honouring the stored
    rectangles at another shape cover-crops a framing somebody chose by
    hand. `"refit"` rebuilds each keyframe at the new aspect,
    **keeping what the author actually chose** — where the camera looks
    at each instant, and how far in it is — and changing only the window
    shape. A cut in a second aspect is a real workflow (a vertical
    edit of a landscape film), and a panel carries *one* path, not one
    per delivery, so “author a second path” is not a remedy a caller
    can take; `"refit"` is.
* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)
* **Returns:**
  A [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath). Duration is not part of it; pass that
  to the renderer.
* **Raises:**
  [**MoveError**](_autosummary/burns.html.md#burns.MoveError) – an unknown move name, a malformed `focus`, a non-positive
      `zoom` or `aspect`, or — under the default
      `on_aspect_mismatch="raise"` — an explicit path whose
      `output_aspect` contradicts `aspect`.

#### NOTE
`image` is opened for every *named* move. For an explicit path it is
read only when refitting, since resolved geometry needs no picture.

### Examples

```pycon
>>> import numpy as np
>>> img = np.zeros((600, 800, 3), dtype='uint8'); img[300:460, 500:700] = 230
```

A named move is a decision, so the seed does not touch it:

```pycon
>>> resolve_move("push_in", image=img, aspect=16 / 9, seed=1) == \
...     resolve_move("push_in", image=img, aspect=16 / 9, seed=999)
True
```

`"auto"` is where the seed does its one job:

```pycon
>>> a = resolve_move("auto", image=img, aspect=16 / 9, seed=3)
>>> a == resolve_move("auto", image=img, aspect=16 / 9, seed=3)
True
```

A drift travels — and `drift_right` ends further right:

```pycon
>>> d = resolve_move("drift_right", image=img, aspect=16 / 9)
>>> d.evaluate(1.0).x > d.evaluate(0.0).x
True
```

An explicit path is the other front door, returned as authored:

```pycon
>>> from burns import BurnsPath, Rect
>>> hand = BurnsPath.from_start_end(
...     Rect(0, 0, 1, 1), Rect(0.1, 0.1, 0.6, 0.6), output_aspect=16 / 9
... )
>>> resolve_move(hand.to_dict(), image=img, aspect=16 / 9) == hand
True
```

Rendering it at another delivery refuses by default, and `"refit"`
keeps the authored look while changing the window shape:

```pycon
>>> resolve_move(hand, image=img, aspect=9 / 16)
Traceback (most recent call last):
    ...
burns.moves.MoveError: ...
>>> v = resolve_move(hand, image=img, aspect=9 / 16,
...                  on_aspect_mismatch="refit")
>>> v.output_aspect == 9 / 16
True
>>> before = [round(c, 4) for c in hand.evaluate(1.0).center]
>>> after = [round(c, 4) for c in v.evaluate(1.0).center]
>>> before == after          # the author's framing, at the new shape
True
```

### burns.salient_box(image, , downscale=320, threshold_pct=72.0, trim_pct=4.0, pad=0.05, min_size=0.35)

Estimate the salient (high-detail) region of `image` as a normalized box.

Uses gradient magnitude: flat regions (sky, walls, water) have low gradient
and fall away, so the bounding box of the high-gradient pixels tracks the
subject. Robust to outliers via percentile trimming. Falls back to a
centered box when the image is too uniform to decide.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float), [`float`](https://docs.python.org/3/builtins/functions.html#float)]

### Examples

```pycon
>>> import numpy as np
>>> a = np.zeros((100, 100), dtype='uint8'); a[60:90, 40:70] = 255
>>> x, y, w, h = salient_box(a, min_size=0.0, pad=0.0)
>>> 0.3 < x < 0.45 and 0.55 < y < 0.65   # box around the bright square
True
```

### Modules

| [`backends`](_autosummary/burns.backends.html.md#module-burns.backends)   | Pluggable single-clip render backends — the open-closed seam.                                                                                       |
|-----------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
| [`content`](_autosummary/burns.content.html.md#module-burns.content)     | Content-aware Ken Burns: choose crop windows that keep the subject (and any detected faces) framed, and avoid drifting over empty regions like sky. |
| [`easing`](_autosummary/burns.easing.html.md#module-burns.easing)       | Timing functions (easing) for Ken Burns motion.                                                                                                     |
| [`moves`](_autosummary/burns.moves.html.md#module-burns.moves)         | The named camera moves, and the one resolver that expands one into a path.                                                                          |
| [`path`](_autosummary/burns.path.html.md#module-burns.path)           | The render-agnostic Ken Burns motion spec: [`BurnsPath`](_autosummary/burns.html.md#burns.BurnsPath).                                              |
| [`rect`](_autosummary/burns.rect.html.md#module-burns.rect)           | The Ken Burns viewport rectangle — the render-agnostic geometric atom.                                                                              |
| [`render`](_autosummary/burns.render.html.md#module-burns.render)       | Ken Burns **renderers** — turn still images into pan/zoom video.                                                                                    |


# _autosummary/burns.moves.html.md

# burns.moves

The named camera moves, and the one resolver that expands one into a path.

A [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) is *resolved geometry*: rectangles measured
against one picture’s pixels. That makes it the wrong thing to store when the
picture can change. What an editor wants to keep is the **authored intent** —
“push in on this, gently” — and have the rectangles computed against whatever
image is in the slot at render time. Replace the still and the move re-frames;
keep the still and the move is exactly what it was.

So this module is the vocabulary ([`MOVES`](_autosummary/burns.moves.html.md#burns.moves.MOVES)) plus the one function that
turns an intent into geometry ([`resolve_move()`](_autosummary/burns.moves.html.md#burns.moves.resolve_move)).

**One code path, two front doors.** `resolve_move` takes a *name* or an
explicit `BurnsPath` (or its `to_dict()` payload), so a caller that stores
both a named move and an optional hand-corrected path override makes one call
and the choice between them is made here — once — rather than once per consumer.
This is `an.ir.camera.camera_keys`’ arrangement: one table, not two tables
reconciled by a test.

**\`\`seed\`\` replaces the ordinal, and does one job.** The motion in a sequence
used to be derived from each panel’s *position* — style by `i % 2`, zoom by
`i % 4`, push-or-pull by the parity of `i`. Reordering one panel therefore
changed the camera on every panel after it, and “keep this move, change this
picture” was unexpressible. `resolve_move` is a pure function of its
arguments and never of a position; a caller mints a `seed` once, stores it
beside the move, and the move survives every reorder.

The seed’s *only* job is choosing which concrete move `"auto"` becomes
([`choose_move()`](_autosummary/burns.moves.html.md#burns.moves.choose_move)). It deliberately does **not** perturb a named move’s zoom
or framing: a named move is a decision, and a decision a seed can still nudge
is not one. Variety across a sequence comes from `"auto"` and from the
pictures themselves — the content-aware framing differs per image already.

```pycon
>>> import numpy as np
>>> img = np.zeros((600, 800, 3), dtype='uint8'); img[380:520, 120:300] = 210
>>> path = resolve_move("push_in", image=img, aspect=16 / 9, seed=41)
>>> path.evaluate(1.0).zoom >= path.evaluate(0.0).zoom
True
>>> resolve_move("hold", image=img, aspect=16 / 9).evaluate(0.0) == \
...     resolve_move("hold", image=img, aspect=16 / 9).evaluate(1.0)
True
```

### Module Attributes

| [`RESOLVER_IMPL_VERSION`](_autosummary/burns.moves.html.md#burns.moves.RESOLVER_IMPL_VERSION)   | The identity of the resolver's *geometry*, for a caller's cache key.                                                                          |
|--------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| [`DIFFUSE_KEEP_AREA`](_autosummary/burns.moves.html.md#burns.moves.DIFFUSE_KEEP_AREA)       | The area (share of the picture) around which a saliency keep-region that reaches the borders stops being a subject and becomes a centre hint. |
| [`DFLT_ZOOM`](_autosummary/burns.moves.html.md#burns.moves.DFLT_ZOOM)               | Default end magnification.                                                                                                                    |
| [`DRIFT_TRAVEL`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_TRAVEL)            | How much of the available travel a drift uses, as a fraction of the room the window leaves inside the image.                                  |
| [`DRIFT_SPAN`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_SPAN)              | A ceiling on that travel, as a fraction of the **window** rather than of the image — how far the camera crosses its own frame.                |
| [`DRIFT_MIN_ROOM`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_MIN_ROOM)          | The least travel room a drift will accept, in image units.                                                                                    |
| [`MOVES`](_autosummary/burns.moves.html.md#burns.moves.MOVES)                   | Every name a stored `move` field may hold — one vocabulary, because it is one field.                                                          |
| [`AUTO_WEIGHTS`](_autosummary/burns.moves.html.md#burns.moves.AUTO_WEIGHTS)            | How often `"auto"` picks each move, as integer weights.                                                                                       |

### Functions

| [`choose_move`](_autosummary/burns.moves.html.md#burns.moves.choose_move)(seed)                                  | Which concrete move `"auto"` resolves to for `seed`.                |
|-----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------|
| [`move_kind`](_autosummary/burns.moves.html.md#burns.moves.move_kind)(move)                                    | How `move` is grouped: `"zoom"`, `"drift"`, `"static"`, `"select"`. |
| [`resolve_move`](_autosummary/burns.moves.html.md#burns.moves.resolve_move)(move, \*, image, aspect[, zoom, ...]) | Resolve an authored camera intent against `image` into a path.      |

### Exceptions

| [`MoveError`](_autosummary/burns.moves.html.md#burns.moves.MoveError)   | A move that cannot be resolved into a path.   |
|--------------------------------------------------------------|-----------------------------------------------|

### burns.moves.AUTO_WEIGHTS *: [Mapping](https://docs.python.org/3/library/typing.html#typing.Mapping)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), [int](https://docs.python.org/3/builtins/functions.html#int)]* *= mappingproxy({'push_in': 3, 'pull_out': 3, 'drift_left': 1, 'drift_right': 1, 'drift_up': 1, 'drift_down': 1})*

How often `"auto"` picks each move, as integer weights. Pushes and pulls
dominate because a commentary film is mostly faces and documents, where a
drift wanders off the thing being talked about; the drifts are there so a
long sequence does not read as one move repeated.
Read-only on purpose. The pool [`choose_move()`](_autosummary/burns.moves.html.md#burns.moves.choose_move) draws from is built once
at import, so assigning into a plain dict here would change the documented
weights and change nothing about the moves — a silent no-op in a name that
is in `__all__`. Retuning these is a burns change that bumps
[`RESOLVER_IMPL_VERSION`](_autosummary/burns.moves.html.md#burns.moves.RESOLVER_IMPL_VERSION), not something a consumer does at runtime.

### burns.moves.DFLT_ZOOM *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 1.18*

Default end magnification. Enough that a slow push reads as movement on a
1080p frame, little enough that a found still is not visibly softened by the
upscale.

A **drift** may render slightly above it: reaching [`DRIFT_SPAN`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_SPAN) needs
[`DRIFT_MIN_ROOM`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_MIN_ROOM) of travel room, which at this zoom is marginally more
than the window leaves, so the floor raises it to ~1.2. That is the floor
working, not a jitter — it is a pure function of the arguments.

### burns.moves.DIFFUSE_KEEP_AREA *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.5*

The area (share of the picture) around which a saliency keep-region that
reaches the borders stops being a subject and becomes a centre hint.
On a detailed photograph gradient saliency marks nearly everything, and
treating that as a subject capped every requested zoom at ~1.0, so
`push_in` rendered its ~1.07 floor whatever was asked (burns#21, measured
on 10 of 10 archive photos). Such a region is replaced by the largest keep-
region that still honours the requested zoom, centred where saliency
points. An explicit `focus` is never treated this way: it is a decision.
It is the centre of a ramp (`DIFFUSE_KEEP_RAMP`) and applies only to a
box touching `DIFFUSE_MIN_EDGES` borders; the default centred box
([`DFLT_KEEP_BOX`](_autosummary/burns.content.html.md#burns.content.DFLT_KEEP_BOX)) touches none, so a uniform picture
keeps its old framing.

### burns.moves.DRIFT_MIN_ROOM *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.16666666666666669*

The least travel room a drift will accept, in image units. When the
requested `zoom` leaves less, the zoom is *raised* until it does — the same
way `content_aware_path`’s `min_zoom` floor wins over its framing cap.
A drift with no room is a hold, and silently returning one is the failure
this floor exists to prevent.

**It is derived, not chosen**, and the derivation is the point. Travel is
`min(room * DRIFT_TRAVEL, size * DRIFT_SPAN)` with `size == 1 - room`, so
the span can only ever bind when
`room >= DRIFT_SPAN / (DRIFT_TRAVEL + DRIFT_SPAN)` — one sixth, at the
values above. Set below that and the two constants can *never* both bind:
the floor silently wins everywhere, every room-limited drift crosses
`DRIFT_TRAVEL * DRIFT_MIN_ROOM / (1 - DRIFT_MIN_ROOM)` of its frame, and
[`DRIFT_SPAN`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_SPAN)’s promise that the two axes read at the same speed holds
only on pictures with room to spare. At `0.08` it measured 2.3x apart on a
portrait still delivered at 16:9 — the exact asymmetry DRIFT_SPAN exists to
remove. `tests/test_moves.py` pins the relationship, so changing either
constant without the other fails.

### burns.moves.DRIFT_SPAN *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.1*

A ceiling on that travel, as a fraction of the **window** rather than of the
image — how far the camera crosses its own frame.

Without it a drift’s speed is whatever room the picture happened to leave,
which is not the same on both axes: on a 4:3 still delivered at 16:9 there is
twice as much vertical room as horizontal, so `drift_up` travelled ~29 % of
the frame where `drift_left` travelled ~9 % — the same named gesture at
three times the speed, decided by the aspect of the picture. `an`’s camera
hit the same asymmetry from the other direction and answered it the same way,
with one fraction of the frame per axis (`an.ir.camera.PAN_FRACTION`).

A tenth is calibrated against what the room-limited horizontal case already
produces at [`DFLT_ZOOM`](_autosummary/burns.moves.html.md#burns.moves.DFLT_ZOOM), and against `ken_burns_path(style="drift")`’s
~7 %: legible at 1080p over a normal panel, and not a whip.

### burns.moves.DRIFT_TRAVEL *: [float](https://docs.python.org/3/builtins/functions.html#float)* *= 0.5*

How much of the available travel a drift uses, as a fraction of the room the
window leaves inside the image. Less than `1.0` on purpose: a drift that
runs wall to wall starts and ends against an edge, which reads as the frame
hitting a stop rather than as a camera continuing past the cut.

### burns.moves.MOVES *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[str](https://docs.python.org/3/builtins/stdtypes.html#str), ...]* *= ('push_in', 'pull_out', 'drift_left', 'drift_right', 'drift_up', 'drift_down', 'hold', 'auto')*

Every name a stored `move` field may hold — one vocabulary, because it is
one field. `"hold"` and `"auto"` sit beside the directional moves for
that reason: splitting them into separate constants would make a consumer
union two tuples to validate one field. They differ in *kind*, not in
membership, and [`move_kind()`](_autosummary/burns.moves.html.md#burns.moves.move_kind) is where that difference is read.

**burns owns this vocabulary.** A consumer that needs to validate its own
stored field before a render (rather than discovering a typo five minutes
in) will mirror it; that mirror must be pinned equal to this tuple by a test
that *fails* when burns is absent rather than skipping, or the two drift
silently. burns is deliberately strict about the spelling — no case folding
and no whitespace stripping — so a mirror written as a plain membership test
agrees with it exactly.

### *exception* burns.moves.MoveError

Bases: [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError)

A move that cannot be resolved into a path.

A plain [`ValueError`](https://docs.python.org/3/builtins/exceptions.html#ValueError) subclass, so a consumer that already catches
`ValueError` around its authoring layer keeps working, and so nothing
downstream needs to import [`burns.moves`](_autosummary/burns.moves.html.md#module-burns.moves) to handle it.

### burns.moves.RESOLVER_IMPL_VERSION *: [int](https://docs.python.org/3/builtins/functions.html#int)* *= 2*

The identity of the resolver’s *geometry*, for a caller’s cache key.

A stored panel is an intent, so the pixels it becomes depend on constants
that live here — [`DFLT_ZOOM`](_autosummary/burns.moves.html.md#burns.moves.DFLT_ZOOM), [`DRIFT_TRAVEL`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_TRAVEL), [`DRIFT_SPAN`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_SPAN),
[`DRIFT_MIN_ROOM`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_MIN_ROOM), [`AUTO_WEIGHTS`](_autosummary/burns.moves.html.md#burns.moves.AUTO_WEIGHTS) and the order
[`choose_move()`](_autosummary/burns.moves.html.md#burns.moves.choose_move) draws from. Retune any of them and an **unchanged** panel
renders differently.

A consumer that caches a render keyed on the panel alone would therefore
serve the old frames forever, or silently produce a cut that disagrees with
its siblings. Put this in the key. It is `nw.Transform.impl_version`’s
contract — *a lock, not a receipt*: bump it whenever the geometry changes for
an unchanged input, and leave it alone for a docstring or a new move name.

Deliberately **not** the package version: a burns release that touches only
the renderer must not invalidate anybody’s cut, and
`importlib.metadata.version("burns")` is unreliable here besides.

### burns.moves.choose_move(seed)

Which concrete move `"auto"` resolves to for `seed`.

Public because “auto” is a decision a person may want to *see* (a UI
showing `auto → drift_left`) and to *pin* (liking what auto chose and
storing that name instead, so it survives a change to the weights).

A pure function of `seed` alone — not of the image, and not of any
position in a sequence. That is what makes a panel’s move survive both a
reorder and a swapped still.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### Examples

```pycon
>>> choose_move(7) == choose_move(7)
True
>>> choose_move(7) in MOVES and choose_move(7) != 'auto'
True
>>> len({choose_move(s) for s in range(60)}) > 1   # not one move forever
True
```

### burns.moves.move_kind(move)

How `move` is grouped: `"zoom"`, `"drift"`, `"static"`, `"select"`.

For a UI that wants a compass of directions separately from the rest, read
off the same table [`resolve_move()`](_autosummary/burns.moves.html.md#burns.moves.resolve_move) dispatches on.

* **Return type:**
  [`str`](https://docs.python.org/3/builtins/stdtypes.html#str)

### Examples

```pycon
>>> move_kind("drift_left"), move_kind("hold"), move_kind("auto")
('drift', 'static', 'select')
>>> sorted(m for m in MOVES if move_kind(m) == 'drift')
['drift_down', 'drift_left', 'drift_right', 'drift_up']
```

### burns.moves.resolve_move(move, , image, aspect, zoom=1.18, focus=None, seed=0, easing='ease-in-out', on_aspect_mismatch='raise', content_box=None)

Resolve an authored camera intent against `image` into a path.

* **Parameters:**
  * **move** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath), [`Mapping`](https://docs.python.org/3/library/typing.html#typing.Mapping)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]]) – a name from [`MOVES`](_autosummary/burns.moves.html.md#burns.moves.MOVES), or an explicit
    [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) / its `to_dict()` payload. The two
    are the two front doors on this one path: a stored panel that
    carries both a named move and a hand-corrected override passes the
    override here and the choice is made in one place.
  * **image** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – the still the move is framed against — a path, `PIL.Image` or
    ndarray. **Required, and read every time**: the framing depends on
    what is in the picture, so swapping the still must re-frame. Do not
    cache the returned path against the intent.
  * **aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the delivered `width / height`. `None` means “match the
    image”. Required rather than defaulted, because a cut has a
    delivery size and quietly framing for the image’s aspect instead is
    a cover-crop nobody asked for.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – 

    the end magnification. A pure function of the arguments — never
    jittered by `seed` or by a position — but \*\*bounded at both
    ends\*\*, so the number you pass is a request:
    * capped so the padded keep-region stays framed. A `focus`
      filling the frame therefore yields a nearly static move; the fix
      is a tighter `focus`, not a bigger `zoom`. A keep-region
      that *saliency* found covering [`DIFFUSE_KEEP_AREA`](_autosummary/burns.moves.html.md#burns.moves.DIFFUSE_KEEP_AREA) or more
      of the picture is only a centre hint and does not cap: on a
      detailed photograph it covers nearly everything, and capping on
      it made every zoom render the same ~1.07.
    * floored. `push_in` / `pull_out` inherit
      `content_aware_path`’s `min_zoom + 0.02` (about 1.07), so a
      barely-there 1.02 push renders as 1.07; `hold` has no such floor
      and honours 1.02 exactly. A drift is floored to whatever leaves
      [`DRIFT_MIN_ROOM`](_autosummary/burns.moves.html.md#burns.moves.DRIFT_MIN_ROOM) of travel.
  * **focus** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – an explicit keep-region overriding the saliency estimate. A
    [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect), a normalized `(x, y, w, h)` tuple, or
    any object with `.x/.y/.w/.h`. When given,
    [`salient_box()`](_autosummary/burns.content.html.md#burns.content.salient_box) is not called at all.
  * **seed** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – chooses which move `"auto"` becomes, and nothing else. Mint it
    once per panel and store it; never derive it from a position, which
    is the defect this parameter exists to remove.
  * **easing** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – CSS timing function or callable. A callable is fine here but
    cannot be serialized — see `BurnsPath.to_dict()`. Applies to a
    named move; an explicit path carries its own.
  * **content_box** ([`Any`](https://docs.python.org/3/library/typing.html#typing.Any)) – the part of `image` that is picture, as a normalized
    `(x, y, w, h)` (same forms as `focus`). Saliency runs inside it,
    so the blurred fill or bars a caller composited around a still do
    not read as subject. Ignored when `focus` is given.
  * **on_aspect_mismatch** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – what to do when an explicit path was authored for a
    different `output_aspect` than the one being rendered.
    `"raise"` (default) refuses, because honouring the stored
    rectangles at another shape cover-crops a framing somebody chose by
    hand. `"refit"` rebuilds each keyframe at the new aspect,
    **keeping what the author actually chose** — where the camera looks
    at each instant, and how far in it is — and changing only the window
    shape. A cut in a second aspect is a real workflow (a vertical
    edit of a landscape film), and a panel carries *one* path, not one
    per delivery, so “author a second path” is not a remedy a caller
    can take; `"refit"` is.
* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)
* **Returns:**
  A [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath). Duration is not part of it; pass that
  to the renderer.
* **Raises:**
  [**MoveError**](_autosummary/burns.moves.html.md#burns.moves.MoveError) – an unknown move name, a malformed `focus`, a non-positive
      `zoom` or `aspect`, or — under the default
      `on_aspect_mismatch="raise"` — an explicit path whose
      `output_aspect` contradicts `aspect`.

#### NOTE
`image` is opened for every *named* move. For an explicit path it is
read only when refitting, since resolved geometry needs no picture.

### Examples

```pycon
>>> import numpy as np
>>> img = np.zeros((600, 800, 3), dtype='uint8'); img[300:460, 500:700] = 230
```

A named move is a decision, so the seed does not touch it:

```pycon
>>> resolve_move("push_in", image=img, aspect=16 / 9, seed=1) == \
...     resolve_move("push_in", image=img, aspect=16 / 9, seed=999)
True
```

`"auto"` is where the seed does its one job:

```pycon
>>> a = resolve_move("auto", image=img, aspect=16 / 9, seed=3)
>>> a == resolve_move("auto", image=img, aspect=16 / 9, seed=3)
True
```

A drift travels — and `drift_right` ends further right:

```pycon
>>> d = resolve_move("drift_right", image=img, aspect=16 / 9)
>>> d.evaluate(1.0).x > d.evaluate(0.0).x
True
```

An explicit path is the other front door, returned as authored:

```pycon
>>> from burns import BurnsPath, Rect
>>> hand = BurnsPath.from_start_end(
...     Rect(0, 0, 1, 1), Rect(0.1, 0.1, 0.6, 0.6), output_aspect=16 / 9
... )
>>> resolve_move(hand.to_dict(), image=img, aspect=16 / 9) == hand
True
```

Rendering it at another delivery refuses by default, and `"refit"`
keeps the authored look while changing the window shape:

```pycon
>>> resolve_move(hand, image=img, aspect=9 / 16)
Traceback (most recent call last):
    ...
burns.moves.MoveError: ...
>>> v = resolve_move(hand, image=img, aspect=9 / 16,
...                  on_aspect_mismatch="refit")
>>> v.output_aspect == 9 / 16
True
>>> before = [round(c, 4) for c in hand.evaluate(1.0).center]
>>> after = [round(c, 4) for c in v.evaluate(1.0).center]
>>> before == after          # the author's framing, at the new shape
True
```


# _autosummary/burns.path.html.md

# burns.path

The render-agnostic Ken Burns motion spec: [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath).

A [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) is the single source of truth for \*how the virtual camera
moves over a still image\* — pure data, no image, no encoder, no frame count.
Its one job is [`BurnsPath.evaluate()`](_autosummary/burns.path.html.md#burns.path.BurnsPath.evaluate): given a normalized clock time
`t in [0, 1]` it returns the [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect) viewport at that
instant. That pure `t -> Rect` primitive is what makes the motion unit-
testable without rendering, serializable across the wire as JSON, and
re-implementable identically in JS/TS for an in-browser preview.

The model mirrors the professional NLE consensus and Remotion’s
`interpolate`:

- **Keyframes** are `(t, Rect)` waypoints with `t in [0, 1]`. Two keyframes
  is the canonical Start/End Ken Burns case; N keyframes is the strict
  generalization (a hold is two keyframes with equal rects; a single keyframe is
  also valid and means a static framing).
- **Easing** (a CSS timing function) is composed *over* the geometry —
  `evaluate(t) == geometry(easing(t))` — keeping motion *shape* orthogonal to
  motion *speed*. Default `"ease-in-out"` (the cinematic norm).
- **output_aspect** is metadata: the aspect ratio the render should fill. When
  it differs from the source image’s AR the renderer cover-crops; when `None`
  the output AR equals the image’s. It never affects `evaluate()`.

[`ken_burns_path()`](_autosummary/burns.path.html.md#burns.path.ken_burns_path) is a convenience generator: it maps a small intent
vocabulary (a sequence index, a style, a zoom, a pan) to a cohesive, fully
deterministic [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath), so callers animating a sequence of images get
non-repetitive motion without hand-authoring rectangles.

### Functions

| [`ken_burns_path`](_autosummary/burns.path.html.md#burns.path.ken_burns_path)(index, \*[, style, zoom, pan, ...])   | A deterministic [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) for the `index`-th image of a sequence.   |
|-------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|

### Classes

| [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)(keyframes[, easing, interp, ...])   | A time-parameterized pan/zoom motion over a still image.   |
|------------------------------------------------------------------------------------------------|------------------------------------------------------------|

### *class* burns.path.BurnsPath(keyframes, easing='ease-in-out', interp='linear', output_aspect=None, version=1)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A time-parameterized pan/zoom motion over a still image.

Construct it directly from keyframes, or via [`from_start_end()`](_autosummary/burns.path.html.md#burns.path.BurnsPath.from_start_end) /
[`push_in()`](_autosummary/burns.path.html.md#burns.path.BurnsPath.push_in) for the common cases, or via [`ken_burns_path()`](_autosummary/burns.path.html.md#burns.path.ken_burns_path) for
deterministic per-index motion across a sequence.

* **Parameters:**
  * **keyframes** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`float`](https://docs.python.org/3/builtins/functions.html#float), [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)], [`...`](https://docs.python.org/3/builtins/constants.html#Ellipsis)]) – a sequence of `(t, Rect)` waypoints, `t in [0, 1]`,
    strictly increasing in `t`. Must have at least one entry; the
    first `t` should be `0.0` and the last `1.0` for the whole
    clock to be covered (out-of-range `t` clamps to the ends).
  * **easing** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – a CSS timing-function spec (name / `cubic-bezier(...)` /
    4-tuple) or a callable `[0,1] -> [0,1]`. Default `"ease-in-out"`.
  * **interp** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – geometry interpolation between keyframes. Only `"linear"` is
    implemented; the field exists so the spec can carry richer schemes
    (`"catmull-rom"`, `"bezier"`) without a format change.
  * **output_aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – the aspect ratio (`width / height`) the render should
    fill. `None` means “match the source image”.
  * **version** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – spec schema version (for forward-compatible serialization).

### Examples

```pycon
>>> p = BurnsPath.from_start_end(
...     Rect(0, 0, 1, 1), Rect.from_center_zoom(0.5, 0.5, 1.3)
... )
>>> p.evaluate(0.0)
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> round(p.evaluate(1.0).zoom, 4)
1.3
>>> p.duration_keyframes  # number of waypoints
2
```

#### *property* duration_keyframes *: [int](https://docs.python.org/3/builtins/functions.html#int)*

Number of keyframe waypoints (`2` for the canonical Start/End).

#### evaluate(t)

The viewport `Rect` at normalized clock time `t in [0, 1]`.

Applies easing to the clock, then linearly interpolates the geometry at
the eased progress. Pure and deterministic: no image, no I/O, no frame
count. `t` outside `[0, 1]` clamps to the nearest end.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> p = BurnsPath.from_start_end(
...     Rect(0, 0, 1, 1), Rect(0, 0, 0.5, 0.5), easing="linear"
... )
>>> p.evaluate(0.5)
Rect(x=0.0, y=0.0, w=0.75, h=0.75)
```

#### *classmethod* from_dict(d)

Rebuild a [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) from [`to_dict()`](_autosummary/burns.path.html.md#burns.path.BurnsPath.to_dict) output.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> p = BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
>>> BurnsPath.from_dict(p.to_dict()) == p
True
```

#### *classmethod* from_start_end(start, end, , easing='ease-in-out', output_aspect=None)

The canonical two-rectangle Ken Burns case (Start frame -> End frame).

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
...
BurnsPath(keyframes=((0.0, Rect(...)), (1.0, Rect(...))), ...)
```

#### *classmethod* push_in(zoom=1.3, , to=(0.5, 0.5), easing='ease-in-out', output_aspect=None)

The 90%-case constructor: a slow push from the full image toward
`to` at `zoom`.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> round(BurnsPath.push_in().evaluate(1.0).zoom, 4)
1.3
```

#### reversed()

Swap start and end (the NLE “Swap Start and End Areas” button).

Mirrors every keyframe’s time about `0.5` and re-sorts, so the motion
plays back-to-front. Easing/interp/output_aspect are preserved.

* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)

### Examples

```pycon
>>> p = BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
>>> p.reversed().evaluate(0.0)
Rect(x=0.0, y=0.0, w=0.5, h=0.5)
```

#### to_dict()

Serialize to the versioned JSON wire format (the cross-language SSOT).

Easing is stored as the original CSS string / 4-tuple. A callable easing
cannot be serialized and raises — use a CSS spec for paths that travel.

* **Return type:**
  [`dict`](https://docs.python.org/3/builtins/stdtypes.html#dict)[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Any`](https://docs.python.org/3/library/typing.html#typing.Any)]

### Examples

```pycon
>>> p = BurnsPath.from_start_end(Rect(0, 0, 1, 1), Rect(0, 0, .5, .5))
>>> d = p.to_dict()
>>> d["version"], d["easing"], len(d["keyframes"])
(1, 'ease-in-out', 2)
```

### burns.path.ken_burns_path(index, , style='push', zoom=1.1, pan=0.03, easing='ease-in-out', output_aspect=None)

A deterministic [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) for the `index`-th image of a sequence.

Maps intent to geometry so a sequence of images gets cohesive, non-repetitive
motion without hand-authoring rectangles. Per-index deterministic: identical
args always return an identical path. Duration is *not* part of the path — it
is a render-time parameter (pass it to [`burns.ken_burns_video()`](_autosummary/burns.html.md#burns.ken_burns_video) /
[`burns.ken_burns_film()`](_autosummary/burns.html.md#burns.ken_burns_film)).

Two styles:

- `style="push"` (default) — the “cinematic push”: one slow zoom toward an
  off-center focal point. **Odd indices push in, even indices pull out**, so a
  sequence has visual rhythm without changing direction *within* a shot. The
  focal direction rotates through compass octants per index.
- `style="drift"` — pure horizontal pan at a constant zoom, alternating
  direction per index (odd drifts right, even left). `zoom` is ignored;
  drift derives its own zoom from `pan` so the slide is visible.

* **Parameters:**
  * **index** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – the image’s 1-based position in the sequence.
  * **style** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – `"push"` (default) or `"drift"`.
  * **zoom** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – the zoomed-end magnification (> 1.0) for `"push"`.
  * **pan** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – focal-point offset (`"push"`) or total horizontal travel
    (`"drift"`), in `[0, 1]` image units.
  * **easing** (`Union`[[`str`](https://docs.python.org/3/builtins/stdtypes.html#str), [`Callable`](https://docs.python.org/3/library/typing.html#typing.Callable)[[[`float`](https://docs.python.org/3/builtins/functions.html#float)], [`float`](https://docs.python.org/3/builtins/functions.html#float)], [`Sequence`](https://docs.python.org/3/library/typing.html#typing.Sequence)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]]) – CSS timing function or callable. Default `"ease-in-out"` — the
    cinematic slow-in/slow-out. Pass `"linear"` for constant velocity.
  * **output_aspect** ([`Optional`](https://docs.python.org/3/library/typing.html#typing.Optional)[[`float`](https://docs.python.org/3/builtins/functions.html#float)]) – aspect ratio the render should fill (`None` = match image).
* **Return type:**
  [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)
* **Returns:**
  A [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) (two keyframes — Start and End).

### Examples

```pycon
>>> ken_burns_path(1).evaluate(0.0)  # odd: push in, starts full
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> ken_burns_path(2).evaluate(1.0)  # even: pull out, ends full
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> ken_burns_path(3) == ken_burns_path(3)  # deterministic
True
```


# _autosummary/burns.rect.html.md

# burns.rect

The Ken Burns viewport rectangle — the render-agnostic geometric atom.

A [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect) is a normalized region of interest (ROI) over a source image:
`(x, y, w, h)` with **top-left origin, y-down**, every component a fraction
of the image in `[0, 1]`. This is the convention shared by `videopython`’s
`BoundingBox`, CSS `transform-origin` semantics, and FFmpeg’s coordinate
origin — adopting it (rather than the older center+magnification spec) is what
lets one spec drive a Python renderer, a future JS/TS renderer, and a CSS
preview without each reinventing the pixel mapping.

Zoom is expressed as **window-fraction** (the visible window is a fraction of
the image) rather than magnification; the magnification a backend like FFmpeg
`zoompan` wants is the derived read-only [`Rect.zoom`](_autosummary/burns.rect.html.md#burns.rect.Rect.zoom).

The rect is pure data: no I/O, no image needed to construct or interpolate one.
Only `to_pixels()` takes image dimensions, because that is the single point
where normalized geometry meets a concrete raster.

### Classes

| [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)(x, y, w, h)   | A normalized `(x, y, w, h)` viewport over a source image.   |
|---------------------------------------------------------------------|-------------------------------------------------------------|

### *class* burns.rect.Rect(x, y, w, h)

Bases: [`object`](https://docs.python.org/3/builtins/functions.html#object)

A normalized `(x, y, w, h)` viewport over a source image.

All four components are fractions of the image in `[0, 1]` with a
top-left origin. `(x, y)` is the top-left corner of the window; `w` and
`h` are its width and height. `Rect(0, 0, 1, 1)` is the whole image.

### Examples

```pycon
>>> full = Rect(0.0, 0.0, 1.0, 1.0)
>>> full.center
(0.5, 0.5)
>>> full.zoom
1.0
>>> Rect.from_center_zoom(0.5, 0.5, 2.0)
Rect(x=0.25, y=0.25, w=0.5, h=0.5)
```

#### *property* aspect *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Aspect ratio of the window *in normalized image units* (`w / h`).

Note this is not the rendered-pixel aspect ratio unless the image is
square — the pixel AR is `(w * img_w) / (h * img_h)`. The render path
reconciles the window with the desired output AR via a cover-crop.

#### *property* center *: [tuple](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[float](https://docs.python.org/3/builtins/functions.html#float), [float](https://docs.python.org/3/builtins/functions.html#float)]*

The window’s center `(cx, cy)` in `[0, 1]` image units.

#### clamped()

Slide the window inside the image **without resizing it**.

Clamps the top-left corner so the window “rides the wall” at an edge
rather than shrinking — shrinking the box would change its aspect ratio
and make the rendered frame breathe/stretch. Windows larger than the
image in a dimension are centered in that dimension.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> Rect(0.8, 0.0, 0.5, 0.5).clamped()
Rect(x=0.5, y=0.0, w=0.5, h=0.5)
>>> Rect(-0.2, 0.3, 0.4, 0.4).clamped()
Rect(x=0.0, y=0.3, w=0.4, h=0.4)
```

#### *classmethod* from_center_zoom(cx, cy, zoom=1.0, , aspect=1.0)

Build a rect from a pan center, a zoom, and a window aspect ratio.

This is the bridge from the older center+magnification mental model to
the `(x, y, w, h)` representation. `zoom` is window-fraction
magnification (`1.0` = full image, `> 1.0` = zoomed in). `aspect`
is the window’s normalized `w / h`; the default `1.0` yields an
isotropic window (`w == h`) whose rendered AR equals the source
image’s — reproducing the legacy behavior exactly. The result is
clamped to stay inside the image.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> Rect.from_center_zoom(0.5, 0.5, 1.0)
Rect(x=0.0, y=0.0, w=1.0, h=1.0)
>>> Rect.from_center_zoom(0.75, 0.5, 2.0)  # off-center zoom-in
Rect(x=0.5, y=0.25, w=0.5, h=0.5)
```

#### is_contained()

True when the window lies wholly inside the image.

* **Return type:**
  [`bool`](https://docs.python.org/3/builtins/functions.html#bool)

### Examples

```pycon
>>> Rect(0.1, 0.1, 0.5, 0.5).is_contained()
True
>>> Rect(0.8, 0.0, 0.5, 0.5).is_contained()
False
```

#### lerp(other, t)

Linearly interpolate toward `other` by `t` in `[0, 1]`.

* **Return type:**
  [`Rect`](_autosummary/burns.rect.html.md#burns.rect.Rect)

### Examples

```pycon
>>> Rect(0, 0, 1, 1).lerp(Rect(0.25, 0.25, 0.5, 0.5), 0.5)
Rect(x=0.125, y=0.125, w=0.75, h=0.75)
```

#### to_pixels(img_w, img_h)

Map the (clamped) window to an integer pixel box `(x0, y0, x1, y1)`.

Clamps first so the box is always inside the raster, then rounds (not
truncates) for symmetric integer conversion. The returned box is a
half-open crop region suitable for `ndarray[y0:y1, x0:x1]`.

* **Return type:**
  [`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)]

### Examples

```pycon
>>> Rect(0.0, 0.0, 1.0, 1.0).to_pixels(100, 80)
(0, 0, 100, 80)
>>> Rect.from_center_zoom(0.5, 0.5, 2.0).to_pixels(100, 80)
(25, 20, 75, 60)
```

#### *property* zoom *: [float](https://docs.python.org/3/builtins/functions.html#float)*

Magnification implied by the window, `1 / max(w, h)`.

`1.0` = the full image; `> 1.0` = zoomed in. This is the derived
value an FFmpeg `zoompan` backend consumes; the window-fraction
`(w, h)` is the authoritative, user-facing representation.


# _autosummary/burns.render.html.md

# burns.render

Ken Burns **renderers** — turn still images into pan/zoom video.

Two entry points, both consuming a [`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath) (the
render-agnostic motion spec) plus a render-time `duration`:

- [`ken_burns_video()`](_autosummary/burns.render.html.md#burns.render.ken_burns_video) — one image, one `BurnsPath`, one mp4. Dispatches
  to a pluggable backend (see [`burns.backends`](_autosummary/burns.backends.html.md#module-burns.backends)); the default `"pillow"`
  backend is a lazy moviepy clip, so the whole video is never held in memory.
- [`ken_burns_film()`](_autosummary/burns.render.html.md#burns.render.ken_burns_film) — a sequence of `(image, path, duration)` panels
  rendered as a **single** continuous film (no per-panel intermediate files, no
  concat seams, no per-panel tail freezes), with an optional pre-built audio
  track muxed in.

Use [`burns.ken_burns_path()`](_autosummary/burns.html.md#burns.ken_burns_path) (or `BurnsPath.push_in()`) to build paths
instead of hand-authoring keyframes.

### Functions

| [`ken_burns_film`](_autosummary/burns.render.html.md#burns.render.ken_burns_film)(panels, \*, saveas[, fps, ...])   | Render an N-panel Ken Burns film in a single pass.         |
|---------------------------------------------------------------------------------------------------|------------------------------------------------------------|
| [`ken_burns_video`](_autosummary/burns.render.html.md#burns.render.ken_burns_video)(image[, path, duration, ...])    | Render one image into a pan/zoom video from a `BurnsPath`. |

### burns.render.ken_burns_film(panels, , saveas, fps=30, audio_path=None, codec='libx264', audio_codec='aac', \*\*write_kwargs)

Render an N-panel Ken Burns film in a single pass.

Each panel is an `(image, path, duration_s)` triple
(`burns.PanelInput`): `image` is a path / `PIL.Image` /
`np.ndarray`, `path` a `BurnsPath`, `duration_s` how long the
panel occupies the film. Panels play back-to-back; the camera cuts at panel
boundaries but motion never pauses on a static frame within a panel.

A single `VideoClip` (rather than per-panel render + concatenate) avoids
the concat re-encode seam and per-panel tail freeze, and keeps frame
generation lazy via one global `make_frame(t)` closure.

* **Parameters:**
  * **panels** – iterable of `(image, path, duration_s)` triples.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – output mp4 path (required — a film has no single source image).
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – frame rate.
  * **audio_path** – optional pre-built track (already matching the film
    duration); muxed in when supplied. Per-panel audio assembly is the
    caller’s job — the renderer stays pure visual.
  * **codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **audio_codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **\*\*write_kwargs** – forwarded to `write_videofile`.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  The written mp4 [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path).

### burns.render.ken_burns_video(image, path=BurnsPath(keyframes=((0.0, Rect(x=0.0, y=0.0, w=1.0, h=1.0)), (1.0, Rect(x=0.11538461538461542, y=0.11538461538461542, w=0.7692307692307692, h=0.7692307692307692))), easing='ease-in-out', interp='linear', output_aspect=None, version=1), , duration=2.0, fps=30, saveas=None, output_size=None, backend='pillow', codec='libx264', audio_codec='aac', \*\*write_kwargs)

Render one image into a pan/zoom video from a `BurnsPath`.

* **Parameters:**
  * **image** – path / `PIL.Image` / `np.ndarray`.
  * **path** ([`BurnsPath`](_autosummary/burns.path.html.md#burns.path.BurnsPath)) – the motion spec. Default is a 2-second standard push-in.
  * **duration** ([`float`](https://docs.python.org/3/builtins/functions.html#float)) – clip length in seconds (the path’s clock is normalized, so
    duration is supplied here, not baked into the path).
  * **fps** ([`int`](https://docs.python.org/3/builtins/functions.html#int)) – frames per second.
  * **saveas** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str) | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – output path. Default: the source image’s name with
    `_kenburns` appended (auto-uniquified so an existing file is not
    overwritten). Required-ish when `image` has no source path —
    falls back to a temp file.
  * **output_size** ([`tuple`](https://docs.python.org/3/builtins/stdtypes.html#tuple)[[`int`](https://docs.python.org/3/builtins/functions.html#int), [`int`](https://docs.python.org/3/builtins/functions.html#int)] | [`None`](https://docs.python.org/3/builtins/constants.html#None)) – explicit `(width, height)` (even-snapped). If omitted and
    `path.output_aspect` is set, the size is derived from it; otherwise
    the source image’s size is used.
  * **backend** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – registered render backend name (default `"pillow"`).
  * **codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **audio_codec** ([`str`](https://docs.python.org/3/builtins/stdtypes.html#str)) – forwarded to `write_videofile`.
  * **\*\*write_kwargs** – forwarded to `write_videofile`.
* **Return type:**
  [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path)
* **Returns:**
  The output [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path).

### Examples

```pycon
>>> ken_burns_video("photo.jpg")  # 2s push-in
>>> from burns import ken_burns_path
>>> ken_burns_video(
...     "photo.jpg", ken_burns_path(1), duration=5.0, saveas="out.mp4"
... )
```


# about-this-build.html.md

<!-- generated by epythet -->

# About this build

This documentation was built on **2026-10-14 05:36 UTC** from commit <a href="https://github.com/reeleehq/burns/commit/29ff63e146280d1e5e7ba3c01ba4ccc0c4b83b1a"><code>29ff63e</code></a> on branch <code>main</code>, for **burns 0.0.19** (from <code>pyproject.toml</code>).

#### WARNING
The documentation and the package may be misaligned:

- The documented version (0.0.19) is behind the latest release on PyPI (0.0.20): `pip install burns` gives newer code than these docs describe.

## Source

|                     |                                                                                                                                                       |
|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| Commit              | <a href="https://github.com/reeleehq/burns/commit/29ff63e146280d1e5e7ba3c01ba4ccc0c4b83b1a"><code>29ff63e146280d1e5e7ba3c01ba4ccc0c4b83b1a</code></a> |
| Branch              | <code>main</code>                                                                                                                                     |
| Tags at this commit | none                                                                                                                                                  |
| Working tree        | clean                                                                                                                                                 |
| Remote              | <code>https://github.com/reeleehq/burns</code>                                                                                                        |

## Continuous integration

|              |                                                                                            |
|--------------|--------------------------------------------------------------------------------------------|
| Repository   | <code>reeleehq/burns</code>                                                                |
| Run          | <a href="https://github.com/reeleehq/burns/actions/runs/38517332588">38517332588</a>       |
| Ref          | <code>refs/heads/main</code>                                                               |
| Event commit | <code>29ff63e146280d1e5e7ba3c01ba4ccc0c4b83b1a</code> (in the history of the built commit) |

## Tools

|          |         |
|----------|---------|
| epythet  | 0.2.17  |
| Sphinx   | 9.1.0   |
| docutils | 0.22.4  |
| Python   | 3.12.15 |

## Configuration as resolved

|               |                                                                  |
|---------------|------------------------------------------------------------------|
| theme         | <code>auto</code> (Sphinx theme <code>shibuya</code>)            |
| accent        | <code>#006980</code>                                             |
| api_generator | <code>autosummary</code>                                         |
| ignore        | <code>tests/</code>, <code>scrap/</code>, <code>examples/</code> |
| agent_outputs | <code>true</code>                                                |
| aggregates    | <code>md</code>                                                  |
| ai_artifacts  | <code>true</code>                                                |

## Package on PyPI

Latest release: <a href="https://pypi.org/project/burns/0.0.20/">0.0.20</a>, newer than the documented version (0.0.19).

## Reproduce

```bash
git clone https://github.com/reeleehq/burns && cd burns
git checkout 29ff63e146280d1e5e7ba3c01ba4ccc0c4b83b1a
pip install "epythet==0.2.17"
epythet quickstart . --ignore tests/ scrap/ examples/
```

The same data, for machines: <a href="build_info.json"><code>build_info.json</code></a> (schema version 1).


# ai-agents.html.md

<!-- generated by epythet -->

# For AI agents

`burns` ships artifacts for coding agents alongside its code. This page lists
them, says where each lives in the repository, and points at the
machine-readable copies of this documentation.

## Skills

Skills are folders holding a `SKILL.md` (the [Agent Skills](https://agentskills.io) format): a description that tells an agent when to use it and a body with the procedure. Install one into your agent with `gh skill` (any host: `--agent claude-code`, `copilot`, `cursor`, `codex`, `gemini`), or use the copy bundled in the wheel.

### `burns`

Use when turning a still image (or a sequence of stills) into a pan/zoom video — the “Ken Burns effect” — OR when building a UI to author/select the motion path. Triggers on “ken burns”, “pan and zoom a photo”, “animate a still image”, “make a slideshow with motion”, “zoom into an image as video”, “photo to video”, any use of ken_burns_video / ken_burns_film / ken_burns_path / BurnsPath; on content-aware framing: “keep the subject/face in frame”, “don’t pan over the sky”, content_aware_path / content_aware_path_for / salient_box; on STORING a move as data rather than geometry: “save the camera move”, “the same move on a different picture”, “named camera moves”, MOVES / resolve_move / choose_move / move_kind, push_in / pull_out / drift_left / hold / auto; AND on the TypeScript side: “kenburnz”, “ken burns path entry / selection / cropper UI”, “author a BurnsPath”, mountPathEntry, or work under ts/. Use BEFORE hand-rolling moviepy crop/resize-per-frame logic or a bespoke crop-rect UI.

Source: [`.claude/skills/burns`](https://github.com/thorwhalen/burns/tree/HEAD/.claude/skills/burns).

## Instruction files

Files agents read before working in this repository.

- [`CLAUDE.md`](https://github.com/thorwhalen/burns/tree/HEAD/CLAUDE.md): read by Claude Code

## Machine-readable documentation

This site publishes the same documentation in forms that fit an agent’s context window:

- [`llms.txt`](https://thorwhalen.github.io/burns/llms.txt): an index of every page with a one-line description ([llms.txt](https://llmstxt.org) format)
- [`burns.md`](https://thorwhalen.github.io/burns/burns.md): the whole documentation as one Markdown file
- `<page>.html.md`: a rendered Markdown twin of every page, advertised from each page’s `<head>` with `<link rel="alternate" type="text/markdown">`
- [`objects.inv`](https://thorwhalen.github.io/burns/objects.inv): the Sphinx inventory: a symbol-to-URL index (`sphobjinv convert plain objects.inv -`)


# api.html.md

# API reference

| [`burns`](_autosummary/burns.html.md#module-burns)   | burns — Ken Burns pan/zoom video effects.   |
|-----------------------------------------------------------------------|---------------------------------------------|


