Joining clips is the other everyday FFmpeg job, and like trimming it has a fast way and a safe way. The concat demuxer glues files end to end without re-encoding — instant, but only when every clip has the same format. The concat filter decodes everything and encodes one new video — slower, but it accepts clips of any size and frame rate. This page measures both, shows what happens when you use the fast one on the wrong files, and adds a title card made from an image.
Measured on 2026-11-07 with FFmpeg 9.0.2 (linuxserver/ffmpeg image). Test clips: A — 10 s, 1280×720, 30 fps, H.264 + AAC at 48 kHz; B — 3 s with the same settings; C — 3 s, 640×360, 25 fps, AAC at 44.1 kHz.
The fast way: concat demuxer
printf "file 'a.mp4'\nfile 'b.mp4'\n" > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp4
The list file has one file '…' line per clip, in order; -safe 0 allows paths outside the current folder or with unusual characters. Joining A and B took 0.04 seconds and produced a 13.02-second 1280×720 file — no quality loss, no encoding.
The catch is in the documentation of the concat demuxer: "All files must have the same streams (same codecs, same time base, etc.)." It does not stop you when they differ.
What happens when the clips do not match
We ran the same command on A and C — a different resolution, frame rate and audio sample rate:
printf "file 'a.mp4'\nfile 'c.mp4'\n" > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy out.mp4
FFmpeg did not stop. It printed a stream of warnings like "Non-monotonic DTS; previous: 152392, current: 127304 … This may result in incorrect timestamps in the output file", and wrote a file that:
- claims to be 1280×720 at 30 fps throughout, while its last 3 seconds are 640×360 at 25 fps;
- reports a duration of 11.97 s instead of 13;
- carries 44.1 kHz audio in a stream labelled 48 kHz.
Players handle such files differently — some switch resolution mid-way, some freeze, some play the second part at the wrong speed or out of sync. Phone recordings, screen captures and exports from different apps rarely match exactly, so this is a common cause of "merged video freezes".
Check before joining:
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,sample_rate -of csv=p=0 a.mp4
If any value differs between clips, use the filter below.
The safe way: concat filter
ffmpeg -i a.mp4 -i c.mp4 -filter_complex \
"[1:v]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v1]; \
[1:a]aresample=48000[a1]; \
[0:v][0:a][v1][a1]concat=n=2:v=1:a=1[v][a]" \
-map "[v]" -map "[a]" out.mp4
scale … force_original_aspect_ratio=decreasefits the smaller clip into 1280×720 without distorting it;padfills the rest with black bars, centred;setsar=1makes the pixel shape square soconcataccepts the inputs;fps=30matches the frame rate.aresample=48000matches the audio sample rate.concat=n=2:v=1:a=1joins two segments with one video and one audio stream each; inputs are listed segment by segment, video then audio.
The result was exactly 13.000 s at 1280×720, in 1.54 seconds of encoding. For more clips, add inputs, normalise each the same way and raise n. The concat filter documentation lists its options, and the FFmpeg wiki's Concatenate page compares all three ways FFmpeg can join files, including the concat: protocol for formats such as MPEG-TS that can simply be appended.
Concat demuxer (-c copy) |
Concat filter | |
|---|---|---|
| Speed (13 s result) | 0.04 s | 1.54 s |
| Re-encodes | No | Yes |
| Clips must match | Exactly | No — you normalise them |
| Our mismatched test | Wrong duration, mixed formats | Exact |
A title card from an image
An intro or end card — the episode title, the guest, the date — is a still image turned into a few seconds of video. We tested this with a 1280×720 card in front of clip A:
ffmpeg -i a.mp4 -loop 1 -t 3 -framerate 30 -i card.png \
-f lavfi -t 3 -i anullsrc=channel_layout=mono:sample_rate=48000 \
-filter_complex "[1:v]scale=1280:720,setsar=1,format=yuv420p[intro]; \
[intro][2:a][0:v][0:a]concat=n=2:v=1:a=1[v][a]" \
-map "[v]" -map "[a]" -c:v libx264 -c:a aac with-intro.mp4
-loop 1 -t 3 -framerate 30 -i card.pngrepeats the image as a 3-second, 30 fps video.anullsrcmakes 3 seconds of silence, because every concat segment needs an audio stream. Match itschannel_layoutandsample_rateto your video's audio (ffprobeshows them).format=yuv420pconverts the image's colours to what H.264 players expect.
The result was 13.000 seconds — 3 of card, 10 of video — at 1280×720. For an end card, list the card's segment last.
Making the card itself
FFmpeg joins the card; something has to draw it. We do not edit video, but we do make this kind of image: with Shareglint, a title card is a template — title, guest, date, a photo, your colours — rendered at 1280×720 with "size": "youtube", one request per episode, so every card matches without opening a design tool. The card in our test was the built-in video-thumbnail template:
curl -X POST "https://shareglint.com/v1/images?wait=true" \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "template": "video-thumbnail", "size": "youtube", "scale": 1,
"modifications": { "title": "I hiked the Alps", "highlight": "in 3 days", "channel": "Trail Notes" } }'
"scale": 1 returns the card at exactly 1280×720; without it you get 2560×1440, which the scale=1280:720 step in the command above brings down anyway. The same template, rendered once more, is the episode's YouTube thumbnail (YouTube thumbnails). Our images are opaque, so they work as full-frame cards and thumbnails, not as overlays on top of the video.