FFmpeg Concat Different Resolutions: Normalize, Then Copy

You have eight clips to join: two off an iPhone, three pulled from a shared Drive folder, three straight out of an AI video generator. You write the concat list, run -c copy, and get a file that plays the first clip correctly and then goes sideways, or a terminal full of Non-monotonous DTS in output stream 0:1 with audio that has drifted a full second by the end.
Quick answer: To make FFmpeg concat different resolutions, you have to re-encode somewhere, because the concat demuxer with-c copycopies packets without decoding and requires every input to already share the same codec, resolution, pixel format, frame rate, and audio sample rate. The two working fixes are: normalize each clip to one canvas first (scale+pad+setsar=1+fps, uniform-ar 48000) and then join the normalized files with-f concat -c copy, or do it in one shot with afilter_complexgraph that scales each input and feeds them all to theconcatfilter. If you'd rather not host an encoder or babysit a 40-clip render, FFmpeg Micro does the normalize-and-join as a single composition call with a free tier.
Mismatched resolution isn't the bug. Stream copy is.
Conventional wisdom says the concat demuxer is the fast, lossless way to join videos, and most of the time that's exactly what happens. But -f concat -c copy isn't joining videos. It's copying compressed packets from several containers into one container and rewriting timestamps, and it never looks at a frame. The output file gets one set of stream parameters, taken from the first input. Every packet after that is interpreted with the first clip's headers.
That's why a 1080x1920 phone clip appended to a 1920x1080 export doesn't error out. It plays. It just plays wrong, because the decoder was handed a stream description that stopped being true at the seam.
The same contract covers things people forget are parameters: pixel format (yuv420p vs yuv422p), sample aspect ratio, audio sample rate (44100 from a browser recorder vs 48000 from a camera), channel layout, and the container timescale. Any one of them differing is enough to produce garbage or a hard failure.
What "Non-monotonous DTS" actually means
Non-monotonous DTS in output stream 0:1; previous: 441000, current: 440320; changing to 441001 is FFmpeg telling you a packet arrived with a decode timestamp earlier than the one before it, which the muxer isn't allowed to write. The concat demuxer offsets each file's timestamps by the running total duration. When a clip is variable frame rate, or its container duration disagrees with its last real packet, the next file's first packets land behind the previous file's tail.
It's usually stream 0:1 (audio) that complains loudest, because audio packets have a rigid cadence and no B-frames to hide the gap. -fflags +genpts and -avoid_negative_ts make_zero will quiet the warning. They don't fix the drift, because the underlying problem is that one of your inputs isn't constant frame rate. Screen recorders like OBS and most AI generators emit VFR by default.
Fix 1: normalize to one canvas, then concat with `-c copy`
Normalizing means re-encoding each clip once, on its own, to identical parameters, then joining the results with a stream copy that finishes in seconds. This is the approach to use when you have more than about ten clips, or when clips arrive over time instead of all at once.
- Pick a target canvas and stick to it. 1920x1080 at 30 fps,
yuv420p, 48 kHz stereo AAC covers YouTube, Reels, and most ad platforms. For vertical output use 1080x1920 and change nothing else.
- Scale and pad instead of stretching.
force_original_aspect_ratio=decreasefits the clip inside the canvas,padfills the rest with bars, andsetsar=1kills the sample-aspect-ratio mismatch that breaks the concat filter later.
for f in raw/*; do
ffmpeg -y -i "$f" \
-vf "scale=1920:1080:force_original_aspect_ratio=decrease,\
pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p,fps=30" \
-fps_mode cfr -video_track_timescale 30000 \
-c:v libx264 -preset veryfast -crf 20 \
-c:a aac -b:a 128k -ar 48000 -ac 2 \
"norm/$(basename "${f%.*}").mp4"
done
-fps_mode cfr replaced -vsync cfr in FFmpeg 5.1; older builds still take -vsync. -video_track_timescale 30000 forces every MP4 to use the same track timescale, which removes the rounding jitter that produces DTS warnings even after the frame rates match.
- Build the list and copy. The concat demuxer reads a text file, one
fileline per input, and-safe 0is required for absolute paths.
for f in norm/*.mp4; do printf "file '%s'\n" "$PWD/$f"; done > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy -movflags +faststart final.mp4
Joining 50 normalized one-minute clips this way takes a couple of seconds on a laptop, because no frame is ever decoded. All the CPU went into step 2, where it was parallelizable and where a failure only cost you one clip. -movflags +faststart moves the moov atom to the front so the file streams from S3 without a full download, which is the same flag that prevents the "moov atom not found" errors downstream readers throw at you.
Fix 2: one-pass filter_complex concat
The concat filter decodes everything, so it can join clips whose sources differ, but it still demands that its own inputs match each other. You scale each stream inside the graph, then hand the labeled pairs to concat. Use this when you have two to eight clips and they're all available at once.
ffmpeg -i a.mov -i b.webm -i c.mp4 -filter_complex "\
[0:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p,fps=30[v0];\
[1:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p,fps=30[v1];\
[2:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p,fps=30[v2];\
[0:a]aresample=48000:async=1[a0];[1:a]aresample=48000:async=1[a1];[2:a]aresample=48000:async=1[a2];\
[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[v][a]" \
-map "[v]" -map "[a]" -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 192k -ar 48000 \
-movflags +faststart out.mp4
Skip the per-input scale and you get the error that sends people to forums: Input link in1:v0 parameters (size 1080x1920, SAR 1:1) do not match the corresponding output link in0:v0 parameters (1920x1080, SAR 1:1). That message means the concat filter, not the demuxer, and it's telling you branch 1 doesn't match branch 0.
Picking between them
Both paths encode your footage exactly once, so image quality is a wash. The difference is where the work happens and what happens when it fails.
| Normalize, then `-c copy` | One-pass `filter_complex` | |
|---|---|---|
| Encode passes | one per clip, then a copy join | one, over everything |
| Runs in parallel | yes, each clip is independent | no, one process |
| A bad clip costs you | that clip | the entire render |
| Re-joining later | seconds, no re-encode | full re-render |
| Command length at 40 clips | unchanged | unusable by hand |
| Best at | 10+ clips, clips arriving over time | 2 to 8 clips, all in hand |
The 40-clip case is where teams usually give up on writing this themselves. A faceless-channel builder generating scenes from Runway or Sora gets six seconds back at a time, at whatever resolution the model felt like, and needs them normalized on arrival rather than in one giant graph at midnight. That's the shape FFmpeg Micro is built for: post each clip as it lands, then issue one composition call that normalizes and joins the set, with no encoder to host and no long-running process to keep alive. Same result as the commands above, called from your code, n8n, Make, or Zapier, or an AI agent over MCP.
Pitfalls that survive the obvious fix
Most of the remaining breakage comes from properties you never explicitly set, so they inherit from whatever produced the file.
- Rotation metadata. An iPhone portrait clip is stored as 1920x1080 with a 90-degree display matrix.
ffprobereports landscape, players show portrait. Filters auto-apply the rotation, so the normalize path bakes it in correctly, while-c copycarries the flag from the first input only and everything after it lands sideways. - A clip with no audio track. The concat filter fails outright with a stream specifier that matches no streams. Generate silence for that input with
-f lavfi -t 8 -i anullsrc=channel_layout=stereo:sample_rate=48000and map it into the graph. - Odd dimensions.
height not divisible by 2comes from libx264 withyuv420p. Padding to a fixed even canvas avoids it entirely;scale=-2:1080is the fallback when you don't want a fixed canvas. - Apostrophes in filenames. The concat list format treats
'as a delimiter, sofile 'Bob's clip.mp4'breaks the parse. Escape it as'\''or rename before you generate the list. - Mixed sample rates. 44100 from a browser recording next to 48000 from a camera gives you clicks at each seam and slow drift after several joins. Force
-ar 48000on every input, in both approaches. - Trusting container duration. Some VFR files report a duration that's off by a few frames from the last real packet, which is exactly what produces the DTS warning.
fps=30with-fps_mode cfrrewrites the timeline instead of trusting the metadata.
If you want a real transition at the joins rather than a hard cut, normalize first anyway and then reach for the xfade filter, which has the same matching requirements plus its own offset arithmetic.
FAQ
Why does FFmpeg say "Non-monotonous DTS" when I concat videos?
The Non-monotonous DTS warning means a packet's decode timestamp landed earlier than the previous packet's, which happens at concat seams when an input is variable frame rate or its declared duration disagrees with its last packet. Forcing constant frame rate on every input with fps=30 and -fps_mode cfr before joining removes the cause; -fflags +genpts only hides the message.
Can I merge videos with different frame rates without re-encoding?
No. Merging videos with different frame rates always requires re-encoding at least the clips that don't match your target rate, because -c copy cannot resample a timeline it never decodes. The cheapest version is to re-encode each mismatched clip once to the target rate, then join the set with -f concat -c copy.
How do I merge videos with different resolutions without stretching them?
Use scale=W:H:force_original_aspect_ratio=decrease followed by pad=W:H:(ow-iw)/2:(oh-ih)/2, which fits each clip inside the target canvas at its original aspect ratio and fills the leftover space with bars. Plain scale=1920:1080 distorts anything that isn't already 16:9.
Does the concat demuxer work with MP4 and MOV files in the same list?
The concat demuxer accepts mixed containers in one list, but only if the encoded streams inside them are identical in codec, resolution, pixel format, and audio parameters. An H.264 MOV and an H.264 MP4 exported from the same tool will copy fine; a VP9 WebM in the same list will not.
Why does only the first clip's audio show up in my output?
That symptom means the concat demuxer copied audio packets whose codec or sample rate changed mid-file, so the decoder stopped producing sound after the first clip's parameters no longer applied. Re-encode every input to the same -c:a aac -ar 48000 -ac 2 before joining.
Both fixes are the same job written two ways, and the job doesn't change no matter how many clips you throw at it. If you'd rather send mixed-source clips to an endpoint and get one joined file back, sign up free and make the composition call from whatever you already build in.
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

Put Two Videos Side by Side with FFmpeg: hstack, vstack, xstack
The ffmpeg side by side recipe that actually works: hstack needs matching heights, vstack matching widths, and shortest=1 stops the short clip freezing.

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.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free