Skip to main content

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 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/.

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 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

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

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 drift

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:

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:

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):

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:

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:

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.

# 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.

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.

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.

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 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, *, faces=(), faces_detector=None, index=0, output_aspect=None, **kwargs) The same, deriving subject (salient_box) and faces from the image itself.
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.

Release files for burns 0.0.11

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for burns 0.0.11
File Size Uploaded
burns-0.0.11.tar.gz 2.2 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for burns 0.0.11
File Interpreter ABI Platform
burns-0.0.11-py3-none-any.whl Python 3 none any Details

Total release size: 2.3 MB

Release files / burns-0.0.11.tar.gz

Download URL burns-0.0.11.tar.gz
Size 2.2 MB
Tags Source
SHA-256 checksum
How to use checksums
3affbb8c938d4b087e5ac6efc813ba92147d9835f7e7c578c580ab96c0fb8db2
BLAKE2b-256 checksum
How to use checksums
02a95d3e976fdc52e89cd54f39b475aa012a09c2b8be8c426c7a9aeed4774c72
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / burns-0.0.11-py3-none-any.whl

Download URL burns-0.0.11-py3-none-any.whl
Size 37.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
43b6f02d2486b07badceb7addb89dc32da24b901aedc17a30bb964e151a7a5b7
BLAKE2b-256 checksum
How to use checksums
7cfa596f6c1ddff4a25e41f36bd710ed9ae526f298f889680b136e60db627e5a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.0.16

2 release files

0.0.15

2 release files

0.0.14

2 release files

0.0.13

2 release files

0.0.12

2 release files

This release

0.0.11 This release

2 release files

0.0.9

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

0.0.2

2 release files

0.0.1

2 release files

0.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page