FFmpeg zoompan Filter: Ken Burns Zoom and Pan Without the Jitter

You copy the Ken Burns command off a forum thread, run it on a product photo, and the result shudders. Not a smooth glide across the image, but a twitch every few frames, like the picture is being nudged by hand. The command isn't wrong. The source resolution is.
Quick answer: FFmpeg zoompan shudders because it truncates the crop window's position and size to whole source pixels, so a slow move snaps between integers instead of gliding. Upscale the still first so those rounding errors land below one output pixel: ffmpeg -loop 1 -i photo.jpg -vf "scale=8000:-2,zoompan=z='min(zoom+0.0016,1.4)':d=250:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1920x1080:fps=25,format=yuv420p" -t 10 out.mp4. If you'd rather not install FFmpeg, hold a 48-megapixel intermediate frame in memory, or babysit a render queue, send the same Ken Burns job to FFmpeg Micro as one API call and download the finished MP4.The jitter is integer rounding, not a bad zoom step
FFmpeg zoompan builds each output frame by cropping a rectangle out of the source and scaling that rectangle up to the output size. The rectangle's width is iw/zoom, its position is your x and y expression, and all of it gets truncated to whole source pixels. There is no sub-pixel crop.
Do the math on a 1920-wide photo zooming to 1.4x over 10 seconds at 25 fps. That's 250 frames, so the crop width shrinks from 1920 to 1371, about 2.2 pixels per frame. The centering expression then has to move x by roughly 1.1 pixels per frame, which truncates to 1, 1, 2, 1, 1, 2. That alternation is the shudder. It isn't random and it isn't your encoder.
Upscaling fixes it by changing the unit. Feed zoompan an 8000-wide intermediate and one source pixel is 0.24 output pixels at 1920 wide, so a one-pixel rounding error is invisible. The scaler is doing the interpolation that zoompan won't do for you.
scale=8000:-1 is folklore, not a law. What you actually need is enough source pixels that a single frame of movement rounds to less than one output pixel. About 4x your output width covers slow moves, so 7680 for a 1920 output and 4320 for a 1080-wide vertical. Use -2 rather than -1 for the height so it stays even and yuv420p encodes cleanly.
Zoom in: pick the target, then divide
The zoom step is a per-frame increment, so you derive it instead of guessing. To reach zoom level Z over N frames, the increment is (Z - 1) / N. A 1.4x push over 250 frames is 0.4 / 250 = 0.0016.
ffmpeg -loop 1 -i photo.jpg -vf "\
scale=8000:-2,\
zoompan=z='min(zoom+0.0016,1.4)':d=250:\
x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':\
s=1920x1080:fps=25,format=yuv420p" \
-t 10 -c:v libx264 -crf 20 -preset medium out.mp4
Three parameters have to agree or the clip ends early or loops. d=250 is the number of output frames zoompan generates per input frame, fps=25 is the output rate, and -t 10 cuts the stream. 250 frames at 25 fps is 10 seconds. Set -t longer than d/fps and -loop 1 hands zoompan a second input frame, the zoom resets to 1.0, and your video visibly snaps back to the start.
The min() wrapper is a safety cap, not the driver. zoom refers to the previous output frame's zoom value, so the expression accumulates, and the cap only matters if your math overshoots.
Zoom out from a detail to the whole frame
Zooming out starts at the target zoom and subtracts, and the reliable trigger is the output frame counter, not the zoom value itself.
ffmpeg -loop 1 -i photo.jpg -vf "\
scale=8000:-2,\
zoompan=z='if(lte(on,1),1.5,max(1.001,zoom-0.002))':d=250:\
x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':\
s=1920x1080:fps=25,format=yuv420p" \
-t 10 out.mp4
Starting at 1.5 and subtracting 0.002 for 250 frames lands on 1.0 exactly, which is why the floor is max(1.001, ...). The version you'll see pasted around uses if(lte(zoom,1.0),1.5,...) as the seed test, and it works right up until the zoom actually reaches 1.0, at which point it flips back to 1.5 and the clip bounces. Seed on on instead.
Pan across a photo at a fixed zoom
A pure pan holds the zoom constant and drives x linearly from 0 to the far edge. The full travel is iw - iw/zoom, so divide it across your frame count.
ffmpeg -loop 1 -i photo.jpg -vf "\
scale=8000:-2,\
zoompan=z=1.3:d=300:\
x='(iw-iw/zoom)*on/299':y='ih/2-(ih/zoom/2)':\
s=1920x1080:fps=30,format=yuv420p" \
-t 10 out.mp4
Write the literal 299 rather than referencing d, since duration isn't available in the x expression. For a vertical pan, swap the expressions: hold x centered and run y='(ih-ih/zoom)*on/299'.
Raise the frame rate when the move covers distance
A pan is a long move in a short time, and 25 fps is where it starts to strobe. Crossing 900 output pixels in 5 seconds means 7.2 pixels per frame at 25 fps versus 3 pixels per frame at 60 fps. Same motion, less than half the per-frame jump, and the smear your eye reads as smoothness comes back.
Slow center zooms are the opposite case. A 1.2x push over 10 seconds moves the edges a couple of pixels per frame, and 25 or 30 fps is plenty. Spend the frames where the distance is.
Ken Burns across a folder of product photos
Batch work is where the upscale trick starts to cost you. An 8000x6000 intermediate is 48 megapixels, roughly 144 MB per frame as raw RGB, and every output frame re-scales a crop out of it. Run that across 40 product shots on a laptop and you'll watch a progress bar for a while.
The pattern that survives: render one clip per photo with identical output settings, then concat with stream copy so nothing re-encodes.
mkdir -p clips
i=0
for f in photos/*.jpg; do
ffmpeg -loop 1 -i "$f" -vf "\
scale=8000:-2,\
zoompan=z='min(zoom+0.0025,1.25)':d=100:\
x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':\
s=1080x1350:fps=25,format=yuv420p" \
-t 4 -c:v libx264 -crf 20 -preset medium "clips/$(printf %02d $i).mp4"
i=$((i+1))
done
printf "file '%s'\n" clips/*.mp4 > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy slideshow.mp4
Every clip is 1080x1350 at 25 fps with the same codec, which is the condition -c copy needs. Change resolution or frame rate between clips and the concat demuxer will either fail or produce a file that stutters at the joins. Alternating direction reads better than 12 identical pushes, so gate the zoom expression on $((i%2)) and use the zoom-out form on odd photos.
| FFmpeg on your machine | FFmpeg Micro | |
|---|---|---|
| Setup | Install FFmpeg, tune the upscale per photo size | Sign up, call the API |
| Peak memory | ~144 MB per frame at 8000x6000 | None on your side |
| Batch of 40 | Serial loop, one core, you wait | Jobs run in parallel, webhook on done |
| Failure mode | OOM kill halfway through | Job status you can poll |
If the whole job is "turn a handful of product photos into a slideshow with motion and music," the Product Slideshow blueprint is this post's loop already wired up: upload the photos, pick the options, download the video. The zoom-in and zoom-out blueprints do the single-clip version. Same filter math, no 48-megapixel intermediate on your laptop.
Pitfalls that cost the most time
Six zoompan mistakes eat more time than everything else combined, and they're the ones that send people back to the forums rather than to the docs:
- Output is 1280x720 no matter what. zoompan defaults to
s=hd720. Sets=explicitly, always. dis frames, not seconds.d=5gives you a 5-frame clip, not a 5-second one.- The clip restarts mid-video.
-texceedsd/fps, so-loop 1feeds a second input frame and the zoom accumulator resets. - Filter order. Put
scalebefore zoompan andformat=yuv420pafter it. Overlays, watermarks, and drawtext go after the motion, never before. - Upscaling a small source. An 800-pixel-wide screenshot pushed to 8000 gives you smooth motion over mush. The crop math gets better; the detail does not.
- Odd dimensions.
scale=8000:-1can produce an odd height and libx264 with yuv420p will refuse it. Use-2.
When to reach for something else
Ken Burns on stills is what zoompan is for. On video input it's awkward: you have to set d=1 so each input frame yields one output frame, and drive the zoom from the frame counter on, because the zoom accumulator resets on every input frame. It works, but a crop-and-scale pass with expressions is usually easier to reason about.
If a designer needs to control the motion path visually, per photo, with easing curves they can drag, a template-editor product is the honest answer. Filter expressions are a bad interface for art direction. Where they win is volume: 500 listing photos on a schedule, the same motion every time, no human in the loop. That's also where running the encoder yourself stops paying for itself.
FAQ
Why does my FFmpeg zoompan output shake or stutter?
FFmpeg zoompan truncates the crop rectangle's position and size to whole source pixels, so any move slower than about one pixel per frame alternates between staying put and jumping. Upscaling the source with scale=8000:-2 before the zoompan filter shrinks each rounding error to a fraction of an output pixel and the shake disappears.
Does `d` in zoompan mean seconds or frames?
The d parameter in zoompan is the number of output frames generated per input frame, not seconds. Multiply your target duration by the fps value to get it: 10 seconds at 25 fps is d=250.
How do I make the zoom ease in and out instead of moving at constant speed?
Drive the zoom from the output frame counter with a cosine curve instead of accumulating a fixed increment. For a 1.0 to 1.3 push over 250 frames, use z='1+0.3*(1-cos(PI*on/249))/2', which starts slow, peaks in the middle, and settles gently. Expressions based on on also avoid the drift you get from repeatedly adding to zoom.
Can I run zoompan on a video instead of a still image?
Yes, but set d=1 so each input frame produces exactly one output frame, otherwise zoompan duplicates frames and the clip runs long. Base the zoom expression on on rather than zoom, since the zoom accumulator resets with every new input frame.
How do I add music to a Ken Burns slideshow?
Render the silent slideshow first, then mux the audio in a second pass with -i slideshow.mp4 -i music.mp3 -c:v copy -c:a aac -shortest out.mp4. Keeping motion and audio in separate passes means a music change doesn't force you to re-render every zoom, which matters once you're running this as a batch workflow.
The parameter math above is the whole trick, and it doesn't change whether you run it locally or send it as a job. If you'd rather skip the install and the memory ceiling, the free tier gives you enough runs to put a real batch of photos through it and see the motion for yourself.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

How to Reverse a Video with FFmpeg (and Build a Boomerang Loop)
The ffmpeg reverse video filter buffers every decoded frame into RAM before it emits one. Trim first, reverse the segment, then concat a boomerang loop.

Crossfade Between Clips with FFmpeg xfade (No Editor)
Crossfade clips with ffmpeg xfade: transition types, the offset math people get wrong, carrying audio with acrossfade, and chaining more than two clips.

FFmpeg Two-Pass Encoding vs CRF: When It Actually Helps
FFmpeg two-pass encoding hits an exact file size that CRF can't guarantee. The -pass 1 and -pass 2 commands, when two-pass beats CRF, and the real pitfalls.
Skip the command line
The Zoom In blueprint adds the same move in one pass: upload your clip, download it with the push-in.
Run it (free)