Ken Burns pan/zoom image-to-video with optional animated overlays.
Project description
still-motion
Ken Burns pan/zoom image-to-video with optional animated overlays.
Built from the CineForge episode-intro generator at Trollfabriken AITrix AB, where
ffmpeg's zoompan expressions turned brittle the moment any parameter changed.
Hand-rolled filter strings broke on aspect-ratio edge cases and produced judder at
1080p30 because zoompan operates at working resolution — not at output resolution.
still-motion fixes this with a temporary upscale technique: it scales the source
image to output × temp_scale_factor before the zoompan filter runs, then scales
back down, turning ffmpeg's discrete pixel steps into sub-pixel motion at the final
resolution. A 5-second 1080p30 Ken Burns clip with ease-in-out zoom renders in under
4 seconds on any modern CPU. Pairs with web-overlay for animated graphic overlays
and video-arrange for multi-clip assembly.
What it solves
| Previous problem | Solution |
|---|---|
zoompan expressions broke when resolution or duration changed |
filter_graph.py builds all expressions programmatically from typed Motion parameters |
| Pixel-step judder at 1080p because zoompan works at output resolution | Upscale to output × temp_scale_factor before zoompan; downscale after |
| No easing — linear zoom was the only option without custom expressions | Four built-in easing curves: linear, ease-in, ease-out, ease-in-out |
| Compositing HTML/SVG graphics required a separate ffmpeg pass | with_overlay() chains web-overlay PNG sequences into the same filter graph |
| Title text required a separate ffmpeg invocation | with_title() appends a drawtext= filter to the existing chain |
| Rendering a slideshow meant writing shell scripts to chain clips | Slideshow renders intermediates in parallel and joins them with xfade in one final pass |
Installation
pip install still-motion
For faster image probing (avoids ffprobe subprocess):
pip install "still-motion[fast-probe]"
For HTML/SVG animated overlays via web-overlay:
pip install "still-motion[overlay]"
For direct video-arrange integration:
pip install "still-motion[arrange]"
Install everything:
pip install "still-motion[all]"
Runtime requirement: ffmpeg must be on PATH. Install via
winget install Gyan.FFmpeg (Windows), brew install ffmpeg (macOS), or
apt install ffmpeg (Linux). Or pass RenderConfig(ffmpeg_binary="<path>").
Quick start
from still_motion import KenBurns, RenderConfig
# Build a 5-second clip: zoom from 1.0x to 1.25x, pan from center to upper-right.
# ease-in-out removes the mechanical feel of linear zoom.
clip = KenBurns(
image="poster.jpg",
duration=5.0,
zoom_start=1.0,
zoom_end=1.25,
focus=(0.5, 0.5),
focus_end=(0.7, 0.3),
easing="ease-in-out",
)
# Add a fade-in title — chained, not a separate pass.
clip.with_title("Episode 3", animate="slide-up", size=90)
# Control encoding separately from motion parameters.
config = RenderConfig(crf=18, preset="slow")
output = clip.render("intro.mp4", config=config)
print(output) # /absolute/path/to/intro.mp4
The pipeline
┌──────────────┐
│ source img │ ① load as looped still (-loop 1 -i)
└──────┬───────┘
│
▼
┌──────────────┐
│ scale ×4 │ ② upscale to output × temp_scale_factor (default 4×)
└──────┬───────┘
│
▼
┌──────────────┐
│ zoompan │ ③ animate zoom + pan at working resolution
└──────┬───────┘
│
▼
┌──────────────┐
│ scale ÷4 │ ④ downscale to final output resolution
└──────┬───────┘
│
▼
┌──────────────┐
│ drawtext │ ⑤ optional title with fade/slide/scale-in animation
└──────┬───────┘
│
▼
┌──────────────┐
│ overlay │ ⑥ optional PNG / SVG / web-overlay compositing
└──────┬───────┘
│
▼
┌──────────────┐
│ encode │ ⑦ libx264 / libx265, single ffmpeg invocation
└──────────────┘
Steps ②–⑦ compile into a single -vf or -filter_complex string.
One subprocess. No temp files unless overlays involve PNG sequences.
Configuration
from still_motion import RenderConfig
config = RenderConfig(
width=1920, # output width in pixels
height=1080, # output height in pixels
fps=30, # output frames per second
temp_scale_factor=4, # internal upscale: higher = smoother motion, more memory
video_codec="libx264", # ffmpeg codec name
pixel_format="yuv420p",
crf=20, # quality: 0 (lossless) – 51 (worst); 18–23 is typical
preset="medium", # encoder speed vs compression: ultrafast … veryslow
ffmpeg_binary="ffmpeg",
parallel_workers=1, # for Slideshow: number of concurrent clip renders
verbose_ffmpeg=False, # if True, ffmpeg stderr is shown
)
| Field | Default | Notes |
|---|---|---|
width |
1920 |
Output width in pixels |
height |
1080 |
Output height in pixels |
fps |
30 |
Output frames per second |
temp_scale_factor |
4 |
Upscale multiplier before zoompan; values 4–8 work well |
video_codec |
"libx264" |
Any ffmpeg video encoder name |
pixel_format |
"yuv420p" |
Required for broad player compatibility |
crf |
20 |
Constant Rate Factor; lower = higher quality |
preset |
"medium" |
Encoder preset; "fast" for CI, "slow" for final renders |
ffmpeg_binary |
"ffmpeg" |
Name or absolute path to the ffmpeg executable |
parallel_workers |
1 |
Slideshow parallel clip render threads |
verbose_ffmpeg |
False |
Show ffmpeg stderr output |
RenderConfig is a frozen Pydantic model. All fields are validated on construction.
Inspecting what runs
KenBurns.export_command() returns the full ffmpeg argv without running it.
Use this to audit the filter graph or log commands before rendering.
from still_motion import KenBurns, RenderConfig
clip = KenBurns("photo.jpg", duration=5.0, zoom_end=1.3)
clip.with_title("Paris, 2025")
config = RenderConfig(width=1280, height=720)
# Returns a list[str] — no subprocess is started.
argv = clip.export_command(config=config, output="out.mp4")
print(" ".join(argv))
# ffmpeg -loop 1 -i photo.jpg -t 5.0 -vf scale=5120:2880,zoompan=z='...'...,scale=1280:720,drawtext=... -c:v libx264 ... out.mp4
# Log it at DEBUG before every render:
import logging
logging.basicConfig(level=logging.DEBUG)
clip.render("out.mp4", config=config)
The dry-run CLI flag uses the same method — see the CLI section.
CLI
# Render a single image with default Ken Burns (1.0 → 1.2 zoom, ease-in-out, 5s).
still-motion ken-burns poster.jpg -o intro.mp4
# Pan from top-left to bottom-right with a fade title, 8 seconds.
still-motion ken-burns photo.jpg \
--duration 8 \
--zoom-start 1.05 --zoom-end 1.3 \
--focus 0.1,0.1 --focus-end 0.9,0.9 \
--title "Trollfabriken AITrix AB" --title-animate slide-up \
-o panning.mp4
# Print the ffmpeg command without rendering (dry run).
still-motion ken-burns poster.jpg --dry-run
# Build a slideshow from multiple images with wipeleft transitions.
still-motion slideshow img1.jpg img2.jpg img3.jpg \
--duration 4 --transition wipeleft --transition-duration 0.6 \
-o show.mp4
# Check the installed version.
still-motion --version
Package structure
still-motion/
├── src/
│ └── still_motion/
│ ├── __init__.py ← public API re-exports; ffmpeg startup check
│ ├── cli.py ← ken-burns and slideshow subcommands
│ ├── config.py ← RenderConfig Pydantic model
│ ├── exceptions.py ← StillMotionError, FfmpegError, ProbeError, ZoompanError
│ ├── ffmpeg_runner.py ← the only module that calls subprocess.run
│ ├── filter_graph.py ← zoompan / scale filter string builder + easing functions
│ ├── ken_burns.py ← KenBurns public class; chains filter stages
│ ├── models.py ← Motion and OverlayElement typed models
│ ├── overlay_compose.py ← PNG / SVG / web-overlay compositing
│ ├── probe.py ← image dimensions via Pillow or ffprobe
│ ├── slideshow.py ← Slideshow class; parallel render + xfade concat
│ └── title.py ← drawtext filter builder with animation envelopes
├── tests/
├── benchmarks/
│ └── render_ken_burns.py ← wall-time benchmark for the <4s target
├── docs/
│ └── zoompan_expressions.md ← zoompan expression grammar reference
├── pyproject.toml
├── LICENSE
└── README.md
© Trollfabriken AITrix AB — MIT licensed
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file still_motion-0.1.1.tar.gz.
File metadata
- Download URL: still_motion-0.1.1.tar.gz
- Upload date:
- Size: 26.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
18008ee8fc85f6c339825c554a3c3c60285088e92494dceb6299f3004e08116b
|
|
| MD5 |
cf22270e0f7cede08278e7057fd9e71c
|
|
| BLAKE2b-256 |
c1ea34b6dbc7e3bd2945f70655f6d715b5acb473dd6cea22188b835f86ebf214
|
Provenance
The following attestation bundles were made for still_motion-0.1.1.tar.gz:
Publisher:
release.yml on tomastimelock/still-motion
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
still_motion-0.1.1.tar.gz -
Subject digest:
18008ee8fc85f6c339825c554a3c3c60285088e92494dceb6299f3004e08116b - Sigstore transparency entry: 1601445632
- Sigstore integration time:
-
Permalink:
tomastimelock/still-motion@7561d71896f554d67cac1928e7ca95d1cf44d2ae -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/tomastimelock
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7561d71896f554d67cac1928e7ca95d1cf44d2ae -
Trigger Event:
push
-
Statement type:
File details
Details for the file still_motion-0.1.1-py3-none-any.whl.
File metadata
- Download URL: still_motion-0.1.1-py3-none-any.whl
- Upload date:
- Size: 34.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8495bafc39f6a7ab134bffa83f7675fe2e8ff279e868083282575afe99fc871a
|
|
| MD5 |
b6385276a047324bcd330d6c3ed56c5f
|
|
| BLAKE2b-256 |
271946b2764c668017449e5aa996292156be667dd3580d72374e8497f5e0e451
|
Provenance
The following attestation bundles were made for still_motion-0.1.1-py3-none-any.whl:
Publisher:
release.yml on tomastimelock/still-motion
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
still_motion-0.1.1-py3-none-any.whl -
Subject digest:
8495bafc39f6a7ab134bffa83f7675fe2e8ff279e868083282575afe99fc871a - Sigstore transparency entry: 1601445768
- Sigstore integration time:
-
Permalink:
tomastimelock/still-motion@7561d71896f554d67cac1928e7ca95d1cf44d2ae -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/tomastimelock
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7561d71896f554d67cac1928e7ca95d1cf44d2ae -
Trigger Event:
push
-
Statement type: