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.1.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.1-py3-none-any.whl (34.2 kB view details)

Uploaded Python 3

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

Hashes for still_motion-0.1.1.tar.gz
Algorithm Hash digest
SHA256 18008ee8fc85f6c339825c554a3c3c60285088e92494dceb6299f3004e08116b
MD5 cf22270e0f7cede08278e7057fd9e71c
BLAKE2b-256 c1ea34b6dbc7e3bd2945f70655f6d715b5acb473dd6cea22188b835f86ebf214

See more details on using hashes here.

Provenance

The following attestation bundles were made for still_motion-0.1.1.tar.gz:

Publisher: release.yml on tomastimelock/still-motion

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for still_motion-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 8495bafc39f6a7ab134bffa83f7675fe2e8ff279e868083282575afe99fc871a
MD5 b6385276a047324bcd330d6c3ed56c5f
BLAKE2b-256 271946b2764c668017449e5aa996292156be667dd3580d72374e8497f5e0e451

See more details on using hashes here.

Provenance

The following attestation bundles were made for still_motion-0.1.1-py3-none-any.whl:

Publisher: release.yml on tomastimelock/still-motion

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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