Purpose
Take one still image, output an exact-duration MP4 with a subtle ken-burns push-in or pull-out. The motion primitive for the ffmpeg-motion strategy (see video-orchestrator/orchestrator.md State 3.4 motion strategy classifier).
This atom exists because:
- Editorial illustration / 2D / halftone aesthetics break under generative i2v (Seedance hallucinates photoreal hands, Veo3 adds naturalistic motion, etc. — see LEARNINGS L1).
- The right motion for those aesthetics is "printed page that moves" — subtle camera moves on still frames.
- This recipe lived as a function inside
edit-videos/scripts/build_dynamic.pyand kept getting re-implemented. Promoted to a first-class atom.
When to use
- Editorial illustration ads (New Yorker / Niemann / Steinberg lineage)
- Hand-drawn / 2D / paper-and-ink styles
- Halftone / risograph / printed-magazine aesthetics
- Any AI-generated still image that needs subtle motion without generative video re-imagining
- Orchestrator State 5 (Create clips) when
motion_strategy: ffmpeg_motionis set
Don't use for: photoreal product demos (use Seedance/Veo3), real-person performance footage (use Hedra/Kling), or anything that needs in-frame subject motion (use generative i2v).
Inputs
| Flag | Required | Default | Notes |
|---|---|---|---|
--src |
yes | — | Input PNG/JPG path. Should be ≥ target resolution. |
--dst |
yes | — | Output MP4 path. |
--duration |
yes | — | Seconds, float. Exact output length. |
--zoom-end |
no | 1.06 |
Final zoom factor. 1.03–1.08 for subtle, 1.10–1.15 for stronger. |
--zoom-out |
no | false |
If set, motion reverses: starts at zoom-end, ends at 1.0. |
--fps |
no | 24 |
Frame rate. 24 fps reinforces hand-drawn feel; 30 for crisp graphics. |
--width |
no | 1080 |
Output width. |
--height |
no | 1920 |
Output height. |
--fade-in |
no | 0 |
Fade-in duration in seconds. |
--fade-out |
no | 0 |
Fade-out duration in seconds. |
Workflow
- Verify
ffmpegandffprobeare on PATH. - Call:
python3 skills/atoms/editing/ken-burns-clip/scripts/render.py \ --src path/to/still.png \ --dst clips/beat-1.mp4 \ --duration 4.0 \ --zoom-end 1.06 - Verify output duration matches request:
ffprobe -v error -show_entries format=duration -of csv=p=0 clips/beat-1.mp4
Output
A single MP4 at dst. H.264, yuv420p, faststart, exact duration ±1 frame.
Quality Checks
- Output duration is within 0.05s of
--duration(zoompan frame math can leave a 1-frame remainder; we use-t+trim=duration=to clamp). - Output codec is H.264 / yuv420p (web-safe).
- First and last frame both render cleanly (no zoompan boundary glitch).
Failure Modes
-loop 1used by mistake — zoompan'sd=Nruns once per input frame. With-loop 1, every loop replays the full zoom cycle, multiplying output length (we got 225s instead of 3s in the Rat Park session). The script does not use-loop 1. If you call the underlying ffmpeg directly, do NOT add it.- Source resolution too small — we scale source to 2× target before zooming so the pushed-in frame stays sharp. If source is < target, pixelation creeps in. Use ≥ 1080-wide sources.
- Banding on flat gradients — H.264 at high CRF can band on green/cream gradient backgrounds. Bump to
--crf 16if visible (default 18).
Notes
- Reverse motion (
--zoom-out) is useful for "reflecting back" beats — e.g. holding on a previous scene's final frame as the next narrative unit begins. Mimics a slow camera pull-back. - For ping-pong / flip-book animation (3 keyframes alternating at ~6 fps), use
add-zoom-cutsorcreate-jump-cutsinstead — this atom is for single-image motion only. - Combine with hard-cut concat (no crossfade) for editorial-panel aesthetic. Crossfades between AI-generated stills expose geometry drift (LEARNINGS L2).
Reference implementation
Originally extracted from skills/atoms/editing/edit-videos/scripts/build_dynamic.py:65-90 (render_still function). The hardcoded -loop 1-incompatibility comment in that file is the canonical source of truth.