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, withevaluate(t) -> Rect(pure, deterministic, frame-count-free) and JSONto_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 viaken_burns_path()for deterministic per-index motion across a sequence.- Parameters:
keyframes – a sequence of
(t, Rect)waypoints,t in [0, 1], strictly increasing int. Must have at least one entry; the firsttshould be0.0and the last1.0for the whole clock to be covered (out-of-rangetclamps 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.Nonemeans “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 (
2for the canonical Start/End).
- evaluate(t: float) Rect[source]
The viewport
Rectat normalized clock timet 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.
toutside[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
BurnsPathfromto_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
toatzoom.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.5and 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;wandhare 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.zoomis window-fraction magnification (1.0= full image,> 1.0= zoomed in).aspectis the window’s normalizedw / h; the default1.0yields 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
otherbytin[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 FFmpegzoompanbackend consumes; the window-fraction(w, h)is the authoritative, user-facing representation.
- class burns.RenderBackend(*args, **kwargs)[source]
Encode one image +
BurnsPathinto a video file atoutput.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
BurnsPaththat keeps the subject/faces framed while zooming toward or away from them — the content-aware counterpart ofken_burns_path().The keep-region is the union of
facesif any, elsesubject(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. fromsalient_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 (mirrorsken_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 (fromfacesorfaces_detector(image)) straight fromimage, then callcontent_aware_path().imagemay be a path, PIL image, or ndarray.Keeps burns dependency-light: pass an LLM/cv2/manual
faces_detectorwhen 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 invertsx(t)for the curve parameter (bisection — robust for any monotone-x curve) then returnsy(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):imageis a path /PIL.Image/np.ndarray,pathaBurnsPath,duration_show 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 globalmake_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
BurnsPathfor theindex-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).zoomis ignored; drift derives its own zoom frompanso 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
_kenburnsappended (auto-uniquified so an existing file is not overwritten). Required-ish whenimagehas no source path — falls back to a temp file.output_size – explicit
(width, height)(even-snapped). If omitted andpath.output_aspectis 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
imageas 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