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 can feed a Python renderer today and an in-browser JS/TS renderer tomorrow.

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

  • Rect — a normalized (x, y, w, h) viewport over the image (top-left origin, window-fraction zoom).

  • BurnsPath — keyframes + easing, with evaluate(t) -> Rect (pure, deterministic, frame-count-free) and JSON to_dict / from_dict.

  • ken_burns_path() — build a cohesive, deterministic path per sequence index from a little intent (style / zoom / pan / easing).

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

  • ken_burns_video() — one image -> one mp4 (pluggable backend).

  • ken_burns_film() — a sequence of (image, path, duration) panels -> one continuous mp4 (single encode pass, no seams), with optional audio.

Quickstart:

>>> 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
... )
class burns.BurnsPath(keyframes: tuple[tuple[float, Rect], ...], easing: str | Callable[[float], float] | Sequence[float] = 'ease-in-out', interp: str = 'linear', output_aspect: float | None = None, version: int = 1)[source]

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

Construct it directly from keyframes, or via from_start_end() / push_in() for the common cases, or via ken_burns_path() for deterministic per-index motion across a sequence.

Parameters:
  • keyframes – 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 – a CSS timing-function spec (name / cubic-bezier(...) / 4-tuple) or a callable [0,1] -> [0,1]. Default "ease-in-out".

  • interp – 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 – the aspect ratio (width / height) the render should fill. None means “match the source image”.

  • version – spec schema version (for forward-compatible serialization).

Examples

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

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

evaluate(t: float) Rect[source]

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.

Examples

>>> 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: dict[str, Any]) BurnsPath[source]

Rebuild a BurnsPath from to_dict() output.

Examples

>>> 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: Rect, end: Rect, *, easing: str | Callable[[float], float] | Sequence[float] = 'ease-in-out', output_aspect: float | None = None) BurnsPath[source]

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

Examples

>>> 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: float = 1.3, *, to: tuple[float, float] = (0.5, 0.5), easing: str | Callable[[float], float] | Sequence[float] = 'ease-in-out', output_aspect: float | None = None) BurnsPath[source]

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

Examples

>>> round(BurnsPath.push_in().evaluate(1.0).zoom, 4)
1.3
reversed() BurnsPath[source]

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.

Examples

>>> 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() dict[str, Any][source]

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.

Examples

>>> 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)
class burns.Rect(x: float, y: float, w: float, h: float)[source]

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

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

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[float, float]

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

clamped() Rect[source]

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.

Examples

>>> 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: float, cy: float, zoom: float = 1.0, *, aspect: float = 1.0) Rect[source]

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.

Examples

>>> 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() bool[source]

True when the window lies wholly inside the image.

Examples

>>> 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: Rect, t: float) Rect[source]

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

Examples

>>> 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: int, img_h: int) tuple[int, int, int, int][source]

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

Examples

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

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)[source]

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

Args mirror 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.content_aware_path(img_w: int, img_h: int, *, subject: tuple[float, float, float, float] | None = None, faces: Sequence[tuple[float, float, float, float]] = (), index: int = 0, output_aspect: float | None = None, zoom: float = 1.3, min_zoom: float = 1.05, keep_pad: float = 0.18, mode: str = 'auto', easing: str | Callable[[float], float] | Sequence[float] = 'ease-in-out') BurnsPath[source]

A BurnsPath that keeps the subject/faces framed while zooming toward or away from them — the content-aware counterpart of 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 – source image pixel size (the subject box lives in this space).

  • img_h – source image pixel size (the subject box lives in this space).

  • subject – normalized (x,y,w,h) of the main subject (e.g. from salient_box()). Optional.

  • faces – normalized face boxes; when present they are the keep-region and take priority over subject.

  • index – sequence position; in mode="auto" odd indices push in, even pull out, giving a sequence rhythm (mirrors ken_burns_path).

  • output_aspect – the render’s width/height; defaults to the image’s.

  • zoom – target end magnification (capped to keep the region framed).

  • min_zoom – floor magnification for the non-zoomed end.

  • keep_pad – fractional padding around the keep-region.

  • mode"auto" (alternate by index) · "in" (always toward) · "out" (always away).

Examples

>>> 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: Any, *, faces: Sequence[tuple[float, float, float, float]] = (), faces_detector: Callable[[Any], Sequence[tuple[float, float, float, float]]] | None = None, index: int = 0, output_aspect: float | None = None, **kwargs: Any) BurnsPath[source]

Convenience: derive the subject (via salient_box()) and faces (from faces or faces_detector(image)) straight from image, then call 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.

burns.cubic_bezier(x1: float, y1: float, x2: float, y2: float) Callable[[float], float][source]

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

Examples

>>> 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: str = 'pillow') Callable[[...], Path][source]

Look up a registered backend by name.

Examples

>>> get_backend() is get_backend("pillow")
True
burns.ken_burns_film(panels, *, saveas: str, fps: int = 30, audio_path=None, codec: str = 'libx264', audio_codec: str = 'aac', **write_kwargs) Path[source]

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 – output mp4 path (required — a film has no single source image).

  • fps – 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 – forwarded to write_videofile.

  • audio_codec – forwarded to write_videofile.

  • **write_kwargs

    forwarded to write_videofile.

Returns:

The written mp4 Path.

burns.ken_burns_path(index: int, *, style: str = 'push', zoom: float = 1.1, pan: float = 0.03, easing: str | Callable[[float], float] | Sequence[float] = 'ease-in-out', output_aspect: float | None = None) BurnsPath[source]

A deterministic 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() / 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 – the image’s 1-based position in the sequence.

  • style"push" (default) or "drift".

  • zoom – the zoomed-end magnification (> 1.0) for "push".

  • pan – focal-point offset ("push") or total horizontal travel ("drift"), in [0, 1] image units.

  • easing – CSS timing function or callable. Default "ease-in-out" — the cinematic slow-in/slow-out. Pass "linear" for constant velocity.

  • output_aspect – aspect ratio the render should fill (None = match image).

Returns:

A BurnsPath (two keyframes — Start and End).

Examples

>>> 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 = 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: float = 2.0, fps: int = 30, saveas: str | None = None, output_size: tuple[int, int] | None = None, backend: str = 'pillow', codec: str = 'libx264', audio_codec: str = 'aac', **write_kwargs) Path[source]

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

Parameters:
  • image – path / PIL.Image / np.ndarray.

  • path – the motion spec. Default is a 2-second standard push-in.

  • duration – clip length in seconds (the path’s clock is normalized, so duration is supplied here, not baked into the path).

  • fps – frames per second.

  • saveas – 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 – 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 – registered render backend name (default "pillow").

  • codec – forwarded to write_videofile.

  • audio_codec – forwarded to write_videofile.

  • **write_kwargs

    forwarded to write_videofile.

Returns:

The output Path.

Examples

>>> 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.parse_easing(spec: str | Callable[[float], float] | Sequence[float] = 'ease-in-out') Callable[[float], float][source]

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

>>> 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
burns.register_backend(name: str, backend: Callable[[...], Path]) None[source]

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

burns.salient_box(image: Any, *, downscale: int = 320, threshold_pct: float = 72.0, trim_pct: float = 4.0, pad: float = 0.05, min_size: float = 0.35) tuple[float, float, float, float][source]

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.

Examples

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