Metadata-Version: 2.4
Name: still-motion
Version: 0.1.1
Summary: Ken Burns pan/zoom image-to-video with optional animated overlays.
Project-URL: Homepage, https://github.com/tomastimelock/still-motion
Project-URL: Repository, https://github.com/tomastimelock/still-motion
Project-URL: Issues, https://github.com/tomastimelock/still-motion/issues
Author-email: Trollfabriken AITrix AB <dev@trollfabriken.se>
License-Expression: MIT
License-File: LICENSE
Keywords: animation,ffmpeg,ken-burns,slideshow,video,zoompan
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: pydantic>=2.5
Provides-Extra: all
Requires-Dist: pillow>=10.0; extra == 'all'
Requires-Dist: video-arrange>=0.1; extra == 'all'
Requires-Dist: web-overlay>=0.1; extra == 'all'
Provides-Extra: arrange
Requires-Dist: video-arrange>=0.1; extra == 'arrange'
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pillow>=10.0; extra == 'dev'
Requires-Dist: pytest-cov>=4; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: fast-probe
Requires-Dist: pillow>=10.0; extra == 'fast-probe'
Provides-Extra: overlay
Requires-Dist: web-overlay>=0.1; extra == 'overlay'
Description-Content-Type: text/markdown

# 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

```bash
pip install still-motion
```

For faster image probing (avoids ffprobe subprocess):

```bash
pip install "still-motion[fast-probe]"
```

For HTML/SVG animated overlays via web-overlay:

```bash
pip install "still-motion[overlay]"
```

For direct video-arrange integration:

```bash
pip install "still-motion[arrange]"
```

Install everything:

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

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

```python
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.

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

```bash
# 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
