Put Two Videos Side by Side with FFmpeg: hstack, vstack, xstack

You paste hstack into a terminal, point it at two clips, and FFmpeg refuses to run. Or worse, it runs, and one half of the frame freezes on a still image halfway through while the other half keeps playing. Both failures have the same cause, and neither is in the filter documentation's first paragraph.
Quick answer: To put two videos side by side with FFmpeg, normalize both clips to the same height and pixel format first, then stack them:ffmpeg -i left.mp4 -i right.mp4 -filter_complex "[0:v]scale=-2:1080,setsar=1,format=yuv420p[l];[1:v]scale=-2:1080,setsar=1,format=yuv420p[r];[l][r]hstack=inputs=2:shortest=1[v]" -map "[v]" out.mp4. The ffmpeg side by side command fails without that scaling step because hstack requires every input to have identical height, and withoutshortest=1the shorter clip freezes on its last frame until the longer one finishes. If you'd rather not host an encoder or wait out long renders, FFmpeg Micro runs the same layout as one API call on a free tier.
Most guides hand you hstack=inputs=2 and stop there. That command works on two clips exported from the same camera on the same day, which is why it survives in blog posts, and it falls over on real footage. The filter isn't broken. hstack is a compositing primitive, not a layout engine, and it assumes you already did the normalizing yourself.
hstack matches on height, vstack matches on width
FFmpeg's hstack filter places inputs left to right and requires all of them to be the same height and the same pixel format. vstack places inputs top to bottom and requires the same width. Miss either and FFmpeg stops at graph configuration time with a message like Input 1 height 720 does not match input 0 height 1080, followed by Failed to configure output pad on Parsed_hstack_0.
That's the whole reason the split screen you saw in a tutorial won't reproduce. A phone clip at 1080x1920 and a screen recording at 1920x1080 have nothing in common dimensionally, so the fix is a scale in front of each input:
ffmpeg -i left.mp4 -i right.mp4 -filter_complex "\
[0:v]scale=960:1080:force_original_aspect_ratio=decrease,\
pad=960:1080:(ow-iw)/2:(oh-ih)/2:black,setsar=1,format=yuv420p[l];\
[1:v]scale=960:1080:force_original_aspect_ratio=decrease,\
pad=960:1080:(ow-iw)/2:(oh-ih)/2:black,setsar=1,format=yuv420p[r];\
[l][r]hstack=inputs=2:shortest=1[v]" \
-map "[v]" -c:v libx264 -crf 20 -preset medium -pix_fmt yuv420p out.mp4
Each branch gets scaled to fit inside a 960x1080 half, then padded with black to fill that half exactly. Output is 1920x1080. force_original_aspect_ratio=decrease is what stops the pad from being useless: without it, scale=960:1080 stretches faces sideways and nobody notices until the client does.
Two details in that graph earn their place. setsar=1 normalizes the sample aspect ratio, because a clip with a non-square SAR will stack at the wrong apparent width even when the pixel height matches. format=yuv420p forces both branches to the same pixel format, which is the second requirement people skip. A 10-bit HDR phone clip stacked next to an 8-bit screen recording is a format mismatch, not a size mismatch, and the error text points at the wrong thing.
For a vertical split, a before/after ad stacked top over bottom, swap the geometry: pad each branch to 1080x960 and use vstack=inputs=2:shortest=1.
shortest=1 is the option that stops the freeze
The shortest=1 flag tells the stack filter to end output as soon as the shortest input runs out. Its default value is 0, which means the shorter clip holds its final frame on screen for the rest of the render. If your left clip is 12 seconds and the right one is 45, you get 12 seconds of split screen and 33 seconds of a still photo next to moving video.
This is FFmpeg's framesync behavior, shared by overlay, hstack, vstack, and xstack. It is not a bug, and it's genuinely what you want when you're compositing a static logo over a video. It's almost never what you want for a comparison.
Worth separating two things that look identical in a command line: shortest=1 inside the filter is a filter option, while -shortest before the output file is a muxer option that truncates the output to the shortest stream. They solve different problems and you sometimes need both. If your audio is longer than your stacked video, the filter option won't help you.
xstack handles 2x2 grids and anything larger
For four clips in a quad view, xstack is the filter, and it needs an explicit layout describing where each input's top-left corner sits. Every tile still has to be scaled first, and every tile dimension should be even so libx264 can encode yuv420p without complaint.
ffmpeg -i a.mp4 -i b.mp4 -i c.mp4 -i d.mp4 -filter_complex "\
[0:v]scale=640:360,setsar=1,format=yuv420p[v0];\
[1:v]scale=640:360,setsar=1,format=yuv420p[v1];\
[2:v]scale=640:360,setsar=1,format=yuv420p[v2];\
[3:v]scale=640:360,setsar=1,format=yuv420p[v3];\
[v0][v1][v2][v3]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0:shortest=1[v]" \
-map "[v]" -c:v libx264 -crf 20 grid.mp4
The layout string reads as coordinate pairs separated by |, where w0 and h0 mean "the width of input 0" and "the height of input 0." So w0_h0 is the bottom-right tile. Output here is 1280x720. Recent FFmpeg builds also accept grid=2x2 as shorthand, which saves you the coordinate arithmetic. Run ffmpeg -h filter=xstack to confirm your build has it before you rely on it in a script.
If you pass a layout with fewer entries than inputs, xstack fails with a complaint about undefined windows rather than guessing. That strictness is why xstack is safe to generate programmatically and painful to type by hand.
| Filter | Inputs must share | Best for |
|---|---|---|
| `hstack` | Height and pixel format | Split-screen reaction, side-by-side comparison |
| `vstack` | Width and pixel format | Before/after ads, vertical stacking for Reels |
| `xstack` | Nothing (you set every tile position) | 2x2 quad views, 3x3 grids, uneven layouts |
Both audio tracks need their own filter
Stacking video does nothing to audio. With no -map for sound you get a silent file, and with -map 0:a you get only the left clip's audio. To hear both, mix them:
-filter_complex "...[l][r]hstack=inputs=2:shortest=1[v];\
[0:a][1:a]amix=inputs=2:duration=shortest[a]" -map "[v]" -map "[a]"
amix divides volume by the number of inputs by default, so a two-way mix comes out roughly 6 dB quieter than either source. Add normalize=0 to keep original levels, then watch for clipping, or set per-input weights. If you're layering a music bed under the mix instead of two dialogue tracks, the background music post covers ducking and level control in more depth.
The same layout as one composition request
The filter graph above is the easy part to write once and the hard part to run in production. Split-screen renders are CPU-bound H.264 encodes: a pair of 60-second 1080p clips at -preset medium takes roughly a minute of wall clock on a laptop, and a 2x2 grid of the same length takes longer because there are four decodes feeding one encode. Put that inside an n8n or Make workflow and you're now hosting an FFmpeg binary somewhere with enough CPU and a timeout long enough to survive it.
That's the step FFmpeg Micro takes off your plate. You send the two source URLs and the layout as one request to the composition API, the job runs on managed FFmpeg, and you poll or take a webhook when the output is ready. No encoder to install, no version drift between your laptop and your server, no long-running HTTP request to babysit. You can try a stack against your own files in the playground before writing any code.
curl -X POST https://api.ffmpeg-micro.com/v1/jobs \
-H "Authorization: Bearer $FFMPEG_MICRO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inputs": ["https://example.com/left.mp4", "https://example.com/right.mp4"],
"filter_complex": "[0:v]scale=960:1080:force_original_aspect_ratio=decrease,pad=960:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p[l];[1:v]scale=960:1080:force_original_aspect_ratio=decrease,pad=960:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p[r];[l][r]hstack=inputs=2:shortest=1[v]",
"output": {"format": "mp4"}
}'
Field names and the full job schema live in the docs. The filter graph is the same one you tested locally, which is the point.
Pitfalls that cost the most time
Frame rate mismatch is the one that produces the strangest results. Stack a 24 fps clip with a 60 fps clip and framesync will duplicate or drop frames to line them up, which reads as stutter on one side. Add fps=30 to each branch before the stack and the problem disappears.
A few more worth checking before you blame the filter:
- Odd output dimensions break yuv420p encoding with
width not divisible by 2. Scale to even numbers, or usescale=-2:1080so FFmpeg rounds for you. - Rotated phone footage stacks sideways because the rotation lives in metadata, not pixels. Check
ffprobefor a side data display matrix and applytransposeexplicitly. hstack=inputs=3works fine for a triple split. Every input still has to match on height.- Stacking always re-encodes. There is no stream-copy path for a filter graph, so budget encode time accordingly and pick
-crf 18to-crf 23rather than accepting the default.
The same normalize-then-composite pattern shows up everywhere in FFmpeg. It's the reason overlaying an image on video needs a scale first, and the reason joining an intro to a main clip needs matching resolution and timebase before concat will touch it.
FAQ
Why does hstack say the input heights don't match?
FFmpeg's hstack filter requires every input to have the exact same pixel height, and it refuses to configure the filter graph when they differ. Insert scale in front of each input to bring them to a common height, and add setsar=1 so a non-square sample aspect ratio doesn't shift the apparent width after scaling.
How do I stop the shorter video from freezing on its last frame?
Add shortest=1 to the stack filter, as in hstack=inputs=2:shortest=1. The default framesync behavior repeats the last frame of any input that ends early, so a 12-second clip stacked with a 45-second clip produces 33 seconds of a still image unless you set that option.
Can I put videos side by side without re-encoding?
No. Any FFmpeg filter graph, including hstack, vstack, and xstack, decodes frames, composites them, and encodes a new stream, so -c copy is not an option for a side-by-side render. Choose the encoder settings deliberately with -crf and -preset instead of accepting whatever the default gives you.
What's the difference between xstack and hstack for a video grid?
The xstack filter positions every tile explicitly through a layout string, so it can build 2x2 quads, 3x3 grids, and uneven arrangements that hstack and vstack can't express. hstack and vstack are the single-row and single-column special cases, and they're shorter to type when that's all you need.
How do I keep the audio from both clips in a split screen?
Add an amix=inputs=2:duration=shortest branch to your filter graph and map its output alongside the stacked video. Keep in mind that amix divides the volume by the number of inputs by default, so pass normalize=0 or set explicit weights if the mix comes out too quiet.
Get the filter graph right once, then stop running it on your own hardware. Sign up free and send your first split-screen job as a single API call from code, n8n, Make, Zapier, or an AI agent.
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

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.

Create an HLS stream with FFmpeg, no media server needed
Create HLS stream FFmpeg commands for .m3u8 playlists and .ts segments, the CDN setup that makes them play, and the one-call API that skips the encoder box.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free