Skip to main content

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

still_motion-0.1.2.tar.gz (26.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

still_motion-0.1.2-py3-none-any.whl (34.2 kB view details)

Uploaded Python 3

File details

Details for the file still_motion-0.1.2.tar.gz.

File metadata

  • Download URL: still_motion-0.1.2.tar.gz
  • Upload date:
  • Size: 26.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: Hatch/1.17.0 {"ci":null,"cpu":"AMD64","implementation":{"name":"CPython","version":"3.13.7"},"installer":{"name":"hatch","version":"1.17.0"},"openssl_version":"OpenSSL 3.0.16 11 Feb 2025","python":"3.13.7","system":{"name":"Windows","release":"11"}} HTTPX2/2.5.0

File hashes

Hashes for still_motion-0.1.2.tar.gz
Algorithm Hash digest
SHA256 24f28f1caefecf7609b91c9faa274ac98d3ce991c044df3b4d9e05e3e1760f29
MD5 9abbf67201fb5f9c669d376d2c2170bc
BLAKE2b-256 31fcb45982d528dab3036ca28c7e0093c18c150cdf1d04ff2551e7d34a390c74

See more details on using hashes here.

File details

Details for the file still_motion-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: still_motion-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 34.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: Hatch/1.17.0 {"ci":null,"cpu":"AMD64","implementation":{"name":"CPython","version":"3.13.7"},"installer":{"name":"hatch","version":"1.17.0"},"openssl_version":"OpenSSL 3.0.16 11 Feb 2025","python":"3.13.7","system":{"name":"Windows","release":"11"}} HTTPX2/2.5.0

File hashes

Hashes for still_motion-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 6e50ce26056231118f2849cdb76a41bf79b9c55657fb5cd27d60b2f718ceacc8
MD5 aa4485961a77cc1d7cb2fa48999a6cf2
BLAKE2b-256 6fd9ac62494af7707159cd9817b6e3bd48acd7c5db6e7e0ce53b063910785cea

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page