| name | ken-burns-clip |
| description | Render a single still image as an MP4 with a subtle ken-burns zoom (in or out). Used as the canonical motion primitive for editorial-illustration / 2D / static-image ads where generative video models would be the wrong tool. Fast, deterministic, free. |
ken-burns-clip
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.py and 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_motion is 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
ffmpeg and ffprobe are 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 1 used by mistake — zoompan's d=N runs 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 16 if 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-cuts or create-jump-cuts instead — 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.