# FFmpeg concat videos from different AI models and keep them in sync

Which FFmpeg join to use for clips from Veo, Sora, Kling, Hailuo and others: what 20 models deliver, what each concat method did to mismatched clips, and a script that kept sync.

Author: Ravi Parikh

Canonical: https://voyager.so/blog/ffmpeg-join-ai-video-clips

Published: 2026-10-20T10:45:00-07:00
Updated: 2026-10-20T10:45:00-07:00

![Two frames of the same square test clip after joining. Left, the concat demuxer with re-encoding has stretched it to 16:9 and squashed its circle into an ellipse. Right, after conforming first, it is pillarboxed and the circle is round.](https://cdn.moda.app/voyager/blog/ffmpeg-join-ai-video-clips/hero-a0c8c03f4b9f1ee5.webp)

To join video clips from different AI models with FFmpeg, conform every clip to one format first, then join them. Stream copy with the concat demuxer (`-c copy`) needs files with "the same streams (same codecs, same time base, etc.)" ([FFmpeg formats documentation](https://ffmpeg.org/ffmpeg-formats.html#concat)), and stream copy cannot scale, resample or retime anything because it never decodes ([ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Streamcopy)). Clips from one model at one setting usually qualify; clips from different models usually don't. When we joined five mismatched test clips with the concat demuxer, 26.1 seconds of clips became a 41.2-second file with a stretched clip, a 15-second freeze and a missing clip. Conforming each clip first to 24 fps kept all eight sync markers in this synthetic example within 0.05 ms.

The short version: check that the streams match, stream-copy if they do, and expect a brief held frame at each join if the clips' audio runs long (100 ms per join for Sora 2). For anything mixed, scale every clip to one size with bars or a crop, set one frame rate and 48 kHz stereo audio, cut or pad each clip's audio to a whole number of frames, and keep the audio uncompressed until a single AAC encode at the end. The script in the download does all of that in one line.

Everything here was run with FFmpeg 9.0.2 on macOS 26.5.1 on 20 October 2026, and documentation was read the same day. The model outputs are the samples in our [AI model reference](https://voyager.so/resources/models), generated through fal between 9 September and 19 October 2026. The scripts, test clips and full results are in [a 454 KB download](https://cdn.moda.app/voyager/blog/ffmpeg-join-ai-video-clips/ffmpeg-join-kit-a0d3208204e413a0.zip) (ZIP).

## The commands

### Clips that match: stream copy

When the clips' streams match, typically clips from the same model and settings, list them in order and copy the streams. Nothing is re-encoded, so it takes seconds:

```text
printf "file '%s'\n" 01.mp4 02.mp4 03.mp4 > list.txt
ffmpeg -f concat -i list.txt -c copy -movflags +faststart joined.mp4
```

Simple relative file names pass the concat demuxer's default safety check. Add `-safe 0` before `-i` only when the list holds absolute paths or names with unusual characters ([FFmpeg formats documentation](https://ffmpeg.org/ffmpeg-formats.html#concat)). To check first, run `./inspect_clips.sh 01.mp4 02.mp4 03.mp4` from the download: it prints each clip's streams and gives a preliminary verdict on whether they match. With many clips, zero-pad the names (`01`, `02` ... `12`) so `printf "file '%s'\n" [0-9]*.mp4 > list.txt` lists them in order, and write the output to another folder so a second run does not pick it up.

### Clips that don't match: conform, then join

Unzip the download and run the join script from its folder. It needs bash and an FFmpeg build with libx264, such as Homebrew's; on Windows, run it in WSL.

```text
./join_clips.sh -o joined.mp4 01-veo.mp4 02-sora.mp4 03-hailuo.mp4
```

By default it makes 1280x720, 24 fps, 8-bit H.264 at CRF 18, with each clip's first audio track as 48 kHz stereo. Clips play in the order given. The options:

| Option | Default    | Choices                                                                                                    |
| ------ | ---------- | ---------------------------------------------------------------------------------------------------------- |
| `-s`   | `1280x720` | Any even frame size, such as `1920x1080` or `1080x1920`                                                    |
| `-r`   | `24`       | Any frame rate, such as `30` or `24000/1001`                                                               |
| `-f`   | `pad`      | `pad` fits each clip inside the frame with bars; `crop` fills the frame and crops the overflow             |
| `-t`   | `trim`     | `trim` cuts audio that runs past a clip's last frame; `hold` keeps it and holds the last frame to cover it |
| `-k`   | temporary  | A folder to keep the conformed clips in                                                                    |

It refuses to overwrite an input, including through symlinks or hard links, and stops with an error if the result's frame count or stream lengths come out wrong. It assumes standard-dynamic-range clips whose audio and video both start at zero, which was true of every model output we probed, and warns when a clip's audio starts late.

### A few clips in one command: the concat filter

For two or three clips, the concat filter does the same conforming in one command, at the cost of a long graph. This joins a 24 fps clip with audio, a silent 1366x768 clip and a square clip, each cut to its frame count, with each audio track cut or padded to match:

```text
ffmpeg -i a.mp4 -i b.mp4 -i c.mp4 -f lavfi -i anullsrc=r=48000:cl=stereo -filter_complex "[0:v]scale=1280:720:force_original_aspect_ratio=decrease:reset_sar=1,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24,trim=end_frame=144[v0];[1:v]scale=1280:720:force_original_aspect_ratio=decrease:reset_sar=1,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24,trim=end_frame=141[v1];[2:v]scale=1280:720:force_original_aspect_ratio=decrease:reset_sar=1,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24,trim=end_frame=124[v2];[0:a]aresample=48000,apad,atrim=end_sample=288000[a0];[3:a]atrim=end_sample=282000[a1];[2:a]aresample=48000,apad,atrim=end_sample=248000[a2];[v0][a0][v1][a1][v2][a2]concat=n=3:v=1:a=1[v][a]" -map "[v]" -map "[a]" -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a aac -b:a 192k -movflags +faststart joined.mp4
```

Inputs 0, 1 and 2 are the clips and input 3 is silence. Each `[N:v]` chain fits clip N into 1280x720 with bars, sets square pixels and 24 fps, and `trim=end_frame` cuts it to its frame count: the clip's duration times 24, rounded (6.0 seconds is 144 frames). Each audio chain resamples to 48 kHz and pads or cuts to that many frames of audio (144 / 24 × 48,000 = 288,000 samples); the silent clip `b.mp4` takes 282,000 samples of input 3. `concat=n=3:v=1:a=1` then joins the three video and audio pairs in order. Here `a.mp4`, `b.mp4` and `c.mp4` were the Veo-like, Hailuo-like and square test clips described below, and both markers in each clip with sound came out at 0.0 ms. For your own clips, get each duration with `ffprobe -v error -select_streams v:0 -show_entries stream=duration -of csv=p=0 clip.mp4`, multiply by the frame rate and round for `end_frame`, then multiply the frame count by 48,000 and divide by the frame rate for `end_sample`. These joins are hard cuts; for dissolves between clips, FFmpeg has the `xfade` and `acrossfade` filters ([FFmpeg filters documentation](https://ffmpeg.org/ffmpeg-filters.html#xfade)). For video only, the graph shrinks to:

```text
ffmpeg -i a.mp4 -i b.mp4 -i c.mp4 -filter_complex "[0:v]scale=1280:720:force_original_aspect_ratio=decrease:reset_sar=1,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v0];[1:v]scale=1280:720:force_original_aspect_ratio=decrease:reset_sar=1,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v1];[2:v]scale=1280:720:force_original_aspect_ratio=decrease:reset_sar=1,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24[v2];[v0][v1][v2]concat=n=3:v=1:a=0[v]" -map "[v]" -c:v libx264 -crf 18 -pix_fmt yuv420p -movflags +faststart joined.mp4
```

## What AI video generators deliver

Every one of the 177 clips was H.264 in 4:2:0. Almost everything else differed. These are the streams as ffprobe reported them for text-to-video requests of 4 to 6 seconds, 16:9 wherever the model takes an aspect ratio:

| Model                                                                                                          | Frame rate | Frame size | Audio               | Video time base | Audio minus video |
| -------------------------------------------------------------------------------------------------------------- | ---------: | ---------- | ------------------- | --------------- | ----------------: |
| [Veo 3.1](https://voyager.so/resources/models/veo-3-1), Fast and Lite                                          |         24 | 1280x720   | AAC 48 kHz stereo   | 1/12288         |            +16 ms |
| [Sora 2](https://voyager.so/resources/models/sora-2)                                                           |         30 | 1280x720   | AAC 96 kHz stereo   | 1/15360         |           +100 ms |
| [Wan 2.7](https://voyager.so/resources/models/wan-2-7), [Wan 3.0](https://voyager.so/resources/models/wan-3-0) |         30 | 1280x720   | AAC 44.1 kHz stereo | 1/15360         |            +38 ms |
| [Kling 3 Pro](https://voyager.so/resources/models/kling-3-pro)                                                 |         24 | 1920x1080  | AAC 44.1 kHz stereo | 1/12288         |             −3 ms |
| [Kling 3 Standard](https://voyager.so/resources/models/kling-3-standard)                                       |         24 | 1280x720   | AAC 44.1 kHz stereo | 1/12288         |             −3 ms |
| [Seedance 2.0](https://voyager.so/resources/models/seedance-2-0)                                               |         24 | 1280x720   | AAC 44.1 kHz stereo | 1/12288         |            +20 ms |
| [Seedance 2.5](https://voyager.so/resources/models/seedance-2-5)                                               |         24 | 1280x720   | AAC 32 kHz stereo   | 1/49152         |     −82 or +14 ms |
| [MiniMax H3](https://voyager.so/resources/models/minimax-h3), Max and Max Turbo                                |         24 | 1344x768   | AAC 32 kHz stereo   | 1/24000         |            +17 ms |
| [PixVerse V6](https://voyager.so/resources/models/pixverse-v6)                                                 |         24 | 1280x720   | AAC 32 kHz stereo   | 1/12288         |            −42 ms |
| [Grok Imagine Video 1.5](https://voyager.so/resources/models/grok-imagine-video-1-5)                           |         24 | 1280x720   | AAC 48 kHz stereo   | 1/12288         |            −42 ms |
| [Gemini Omni Flash](https://voyager.so/resources/models/gemini-omni-flash)                                     |         24 | 1280x720   | AAC 48 kHz stereo   | 1/12288         |            +13 ms |
| [LTX-2.3](https://voyager.so/resources/models/ltx-2-3)                                                         |         24 | 1920x1080  | AAC 48 kHz stereo   | 1/12288         |            −32 ms |
| [FLUX 3 Max](https://voyager.so/resources/models/flux-3-max)                                                   |         24 | 1280x704   | AAC 44.1 kHz mono   | 1/12288         |            −42 ms |
| [Hailuo 2.3](https://voyager.so/resources/models/hailuo-2-3)                                                   |         24 | 1366x768   | None                | 1/12288         |               n/a |
| [Luma Ray 3.2](https://voyager.so/resources/models/luma-ray-3-2)                                               |         24 | 1280x720   | None                | 1/12288         |               n/a |

Sources: ffprobe of the model reference samples, eight text-to-video prompts per model (seven for Wan 2.7 and Wan 3.0), read 20 October 2026. Frame sizes follow each request's resolution setting or the model's default. These describe these samples through fal, not every setting or provider. Sora 2 was generated on 9 September 2026; OpenAI removed it from its API on 24 September ([OpenAI deprecations](https://developers.openai.com/api/docs/deprecations)).

The frame rates match what vendors document where they document one: 24 fps for Veo 3.1 ([Gemini API](https://ai.google.dev/gemini-api/docs/veo)) and for Seedance 2.0 and 2.5 ([BytePlus](https://docs.byteplus.com/en/docs/Byteplus_LAS/video_gen_enhanced)), 30 fps for Wan 2.7 ([Alibaba Cloud Model Studio](https://www.alibabacloud.com/help/en/model-studio/text-to-video-guide/)). Three details matter more for joining than the headline specs:

* **Requested length is not delivered length.** A 5-second request usually came back as 121 frames, 5.042 seconds; Hailuo 2.3 turned 6-second requests into 141 frames, 5.875 seconds.
* **Audio rarely ends with the picture.** The last column runs from 82 ms short to 100 ms long, and it is what produces held frames and offsets at joins.
* **Image-to-video follows the image unless you set an aspect ratio.** From the same square product photo, 12 models returned square clips (768x768 to 1440x1440), and none of those requests set a 16:9 ratio; the seven that did came back 16:9. One Kling 3 Standard image-to-video clip also came back with mono audio.

So two clips from different models will usually break the concat demuxer's rule that "all files must have the same streams (same codecs, same time base, etc.)" ([FFmpeg formats documentation](https://ffmpeg.org/ffmpeg-formats.html#concat)). Veo 3.1 and Kling 3 Pro are both 24 fps H.264 and still differ in frame size and sample rate.

## What each join did to five mismatched clips

Real generated clips cannot show a two-millisecond sync error, so we built five small test clips that copy the stream layouts above:

* **Veo-like:** 1280x720 at 24 fps, 48 kHz audio 16 ms longer than the video.
* **Sora-like:** 1280x720 at 30 fps, 96 kHz audio 100 ms longer than the video.
* **Hailuo-like:** 1366x768 at 24 fps, no audio track.
* **MiniMax-like:** square 768x768 at 24 fps, video time base 1/24000, 32 kHz audio.
* **HEVC re-export:** 1920x1080 10-bit HEVC with mono 44.1 kHz audio, standing in for a clip that passed through an upscaler or editor. None of the real model outputs was HEVC.

Every frame carries a barcode of its clip and frame number. Each clip shows a white box on one frame, 1 second from each end, and the four clips with audio beep at exactly those moments. A Python script decodes every frame and beep of a joined file and reports each clip's start, which frames survived, and how far each beep landed from its flash. It locates the flashes by their known frame numbers in the barcode, not by detecting the white box.

![Five test-pattern frames drawn to scale, each with a coloured border and a barcode strip at the top left: Veo-like 1280x720 at 24 fps, Sora-like 1280x720 at 30 fps, Hailuo-like 1366x768 with no audio track, a square 768x768 MiniMax-like clip and a larger 1920x1080 10-bit HEVC clip. Labels give each clip's audio sample rate and how much longer or shorter its audio is than its video.](https://cdn.moda.app/voyager/blog/ffmpeg-join-ai-video-clips/fixtures-3512b98f113f1508.webp)

The five test clips, drawn to scale. The circle shows any stretching; the barcode identifies every frame after a join. Made with FFmpeg's testsrc; the script is in the download.

The five clips hold 650 frames and 26.1 seconds of video. What each method produced:

| Method                                                                                  | What happened                                                                                                                                                                                                                                                                   |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Concat demuxer, `-c copy`                                                               | Finished after 379 "Non-monotonic DTS" warnings. The file ran 41.2 seconds, decoding it logged 91 error lines, and the HEVC clip never appeared.                                                                                                                                |
| Concat demuxer, re-encoding (no `-c copy`)                                              | A clean file with the same broken timeline: 41.2 seconds long with audio ending at 24.1, the square clip stretched to 16:9, the HEVC clip missing, the Hailuo-like clip missing its first 57 frames and then frozen for 15.2 seconds, and the 30 fps clip playing at 80% speed. |
| Concat filter, inputs as they are                                                       | Refused. With audio: "Stream specifier ':a' in filtergraph description ... matches no streams." Video only: "Input link in0:v0 parameters (size 1366x768, SAR 1:1) do not match the corresponding output link in0:v0 parameters (1280x720, SAR 1:1)".                           |
| Concat filter, each clip scaled, padded and converted to 24 fps                         | Every clip present and correctly framed. Beeps landed 16.0 ms late in the Sora-like clip, 9.0 ms early in the square clip and 8.4 ms late in the HEVC clip; the last Sora-like frame stayed on screen for 167 ms.                                                               |
| Same, with each clip's audio cut or padded to its video length                          | Every marker at 0.0 ms, no held frames.                                                                                                                                                                                                                                         |
| Concat filter, scaled and padded, frame rates left as they were (`-fps_mode vfr`)       | All 650 frames kept, every marker at 0.0 ms, a variable-frame-rate file. Where a clip's audio ran long, its last frame stayed up for the extra audio (100 ms after the Sora-like clip).                                                                                         |
| Clips conformed to identical MP4s with AAC audio, then `-c copy`                        | Four "Non-monotonic DTS" warnings. Read by its timestamps, in sync; read as decoded samples played back to back, the audio was 37, 104 and 142 ms late in the second, fourth and fifth clips.                                                                                   |
| `join_clips.sh` at 24 fps: whole-frame lengths, uncompressed audio until one AAC encode | Every marker at 0.0 ms by both readings, 626 of 626 expected frames, video and audio both 26.083 seconds.                                                                                                                                                                       |

"0.0 ms" is the measurement rounded to a tenth of a millisecond, and it covers the eight markers in clips with sound. At 30 fps the same script put one marker 8.3 ms late: converting a 24 fps clip to 30 fps moves some frames by up to half a 30 fps frame while the audio stays put. The hero image above shows the square clip in the re-encoding demuxer join and in the script's join, at the same source frame.

## Why joins drift

Three separate things go wrong, and each has its own fix.

**The formats differ.** The concat demuxer passes each file's packets through as one stream, so a decoder set up for the first clip receives HEVC packets, a new frame size and a new time base. That is why the stream-copy and re-encoding demuxer joins broke the same way: dropping `-c copy` changes the encoder, not the demuxer. The concat filter works on decoded frames instead, and its documentation says it "will automatically select a common pixel format for video streams, and a common sample format, sample rate and channel layout for audio streams, but other settings, such as resolution, must be converted explicitly by the user" ([FFmpeg filters documentation](https://ffmpeg.org/ffmpeg-filters.html#concat)). It also compares pixel aspect ratios, which is why the commands scale with `reset_sar=1` and then set `setsar=1` ([scale filter](https://ffmpeg.org/ffmpeg-filters.html#scale)).

**Audio and video lengths differ inside each clip.** The joiner has to decide where the next clip starts. The concat demuxer starts each file at the previous file's start plus its duration ([concatdec.c in FFmpeg 9.0.2](https://github.com/FFmpeg/FFmpeg/blob/946fcce07b6dcd0331c8cc609192aeff5e1924f8/libavformat/concatdec.c#L381-L386)), and warns that this "may cause gaps if all streams do not have exactly the same length" ([FFmpeg formats documentation](https://ffmpeg.org/ffmpeg-formats.html#concat)). The concat filter "will use the duration of the longest stream in each segment (except the last one), and if necessary pad shorter audio streams with silence" ([FFmpeg filters documentation](https://ffmpeg.org/ffmpeg-filters.html#concat)). Either way, a clip whose audio runs 100 ms long ends with its last frame on screen for an extra 100 ms. When the clips were also converted to 24 fps, the next clip's video started on the 24 fps timestamp grid while its audio started at the exact end of the previous audio, which produced the 16.0, 9.0 and 8.4 ms offsets. Cutting or padding each clip's audio to a whole number of frames before joining removes both effects.

**Every AAC encode adds priming samples.** An AAC encoder emits priming samples before the real audio, and MP4 and QuickTime files use an edit list to tell players to skip them ([Apple QuickTime File Format, audio priming](https://developer.apple.com/documentation/quicktime-file-format/appendix_g_audio_priming_handling_encoder_delay_in_aac)). FFmpeg's own AAC encoder adds 1,024 ([aacenc.c](https://github.com/FFmpeg/FFmpeg/blob/946fcce07b6dcd0331c8cc609192aeff5e1924f8/libavcodec/aacenc.c#L1157-L1168)). In our stream-copy joins of AAC files, only the start of the joined track was trimmed: each later clip's priming and end padding stayed in the track, so the audio timestamps and the decoded samples no longer agreed.

That disagreement is what the two readings in the table measure. Read by its timestamps, the conformed-AAC join was in sync; with its samples played back to back, the sound drifted late, by 142 ms at the fifth clip. Apple's AVFoundation, rendering through `avconvert`, followed the timestamps for that file but not for the plain same-format stream-copy join, which it rendered with audio 7 ms early at the second clip and 14 ms early at the third. A file whose sync depends on the software reading it is the thing to avoid.

The fix is to keep audio uncompressed until one final AAC encode, as `join_clips.sh` does. Its join read 0.0 ms by timestamps, by samples and through AVFoundation.

## What the script does

For each clip, `join_clips.sh` conforms the picture and fixes the length. For the 4-second Sora-like clip at 24 fps:

```text
ffmpeg -i 02.mp4 -filter_complex "[0:v]scale=1280:720:force_original_aspect_ratio=decrease:reset_sar=1,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=24,format=yuv420p,tpad=stop_mode=clone:stop=-1,trim=end_frame=96,setpts=PTS-STARTPTS[v];[0:a:0]aresample=48000,aformat=sample_fmts=s16:channel_layouts=stereo,apad,atrim=end_sample=192000,asetpts=PTS-STARTPTS[a]" -map "[v]" -map "[a]" -c:v libx264 -crf 18 -preset medium -pix_fmt yuv420p -c:a pcm_s16le 002.mov
```

`tpad` and `apad` extend anything that comes up short, `trim` and `atrim` cut anything long, and 96 frames at 24 fps is exactly 192,000 samples at 48 kHz. The intermediate is a MOV with uncompressed 16-bit audio, so no AAC priming exists yet. A clip without audio gets `-f lavfi -i anullsrc=r=48000:cl=stereo` as a second input, trimmed the same way. Then the numbered intermediates are joined, copying the video and encoding the audio once:

```text
printf "file '%s'\n" 001.mov 002.mov 003.mov > list.txt
ffmpeg -f concat -i list.txt -c:v copy -c:a aac -b:a 192k -movflags +faststart joined.mp4
```

The video is encoded once, in the first step, and the audio once, in the second, so each loses one generation.

### Pick one frame rate, or keep both

The `fps` filter converts "by duplicating or dropping frames as necessary" ([FFmpeg filters documentation](https://ffmpeg.org/ffmpeg-filters.html#fps)); it does not invent new ones. At 24 fps, the script dropped 24 of the Sora-like clip's 120 frames, every fifth one (2, 7, 12 and so on). At 30 fps, every 24 fps clip repeated one output frame in five: 36 of the Veo-like clip's 180. Either way, fast motion in the converted clip moves unevenly, so pick the rate most of your clips already use. In the table above that is 24 fps, for 17 of 20 models.

The alternative is not converting. The concat filter accepts mixed frame rates, which "will result in variable frame rate at output" ([FFmpeg filters documentation](https://ffmpeg.org/ffmpeg-filters.html#concat)). MP4 has no constant-frame-rate requirement: its time-to-sample table stores a count and a duration for each run of samples ([Apple QuickTime File Format](https://developer.apple.com/documentation/quicktime-file-format/time-to-sample_atom/time-to-sample_table)). Our variable-frame-rate join kept every source frame, and FFmpeg 9.0.2 also wrote variable frame rate when we left out `-fps_mode vfr`. Note that ffprobe still reported `r_frame_rate` as 24/1 for that file, so read the frame timestamps rather than that field. Before delivering a variable-frame-rate file, check that the editor or platform it goes to accepts one.

### Bars or crop

`-f pad` fits each clip inside the frame and adds black bars, so a square image-to-video clip sits between pillars. `-f crop` fills the frame and crops the overflow, which removes 44% of a square clip's height in a 16:9 frame. A crop suits a background shot; bars suit anything with a subject near the edges. Neither stretches the picture, including clips with non-square pixels, because `reset_sar=1` scales those to square pixels first.

### Audio that outlasts the picture

`-t trim` cuts each clip's audio at its last frame. Cuts stay tight, but the end of a sound that runs past the picture is lost: the last 100 ms of every Sora 2 clip. `-t hold` keeps that audio and repeats the last frame to cover it, which added three frames to the Sora-like clip and one each to the Veo-like and square clips. Use hold when dialogue or a sound effect runs to the very end, and trim when you will lay music or ambience over the whole sequence anyway.

## When stream copy is enough

Clips from one model at one setting usually share codec, size, frame rate and time base, so the concat demuxer joins them without re-encoding. What still varies is the audio overhang. Stream-copying three real text-to-video clips from each of six models:

| Model, three clips | Audio minus video, each clip | At each join                                 |
| ------------------ | ---------------------------: | -------------------------------------------- |
| Sora 2             |                      +100 ms | Last frame on screen 133 ms instead of 33 ms |
| Wan 2.7            |                       +38 ms | Last frame on screen 71 ms instead of 33 ms  |
| Seedance 2.0       |                       +20 ms | Last frame on screen 62 ms instead of 42 ms  |
| Veo 3.1 Fast       |                       +16 ms | Last frame on screen 58 ms instead of 42 ms  |
| Kling 3 Pro        |                        −3 ms | No timestamp hold                            |
| Hailuo 2.3         |                     No audio | No timestamp hold                            |

A 100 ms hold is three frames at 30 fps, enough to notice on a cut in the middle of a movement; 16 ms is less than half a frame. Veo 3.1 Fast, Kling 3 Pro and Wan 2.7 also printed one "Non-monotonic DTS" warning per join, on the audio stream, where FFmpeg moved the next clip's first audio packet to keep timestamps increasing. These checks measure timing at the joins, not lip sync through a particular player, and the AAC priming caveat above applies to any stream-copy join. `same_model_check.sh` in the download repeats this for any model in the table.

When the video streams match but the audio is the problem (a different sample rate, a missing track, or audio longer or shorter than the picture), the picture can stay untouched. Copy each clip's video, conform only its audio to uncompressed 48 kHz cut or padded to the clip's exact video length, then join with one AAC encode. For a 121-frame clip at 24 fps, that length is 121 / 24 × 48,000 = 242,000 samples:

```text
ffmpeg -i 01.mp4 -map 0:v -map 0:a -c:v copy -af "aresample=48000,apad,atrim=end_sample=242000" -c:a pcm_s16le 01.mov
printf "file '%s'\n" 01.mov 02.mov 03.mov > list.txt
ffmpeg -f concat -i list.txt -c:v copy -c:a aac -b:a 192k -movflags +faststart joined.mp4
```

On three same-format test clips whose audio was 42 ms short, this kept all 363 frames untouched and every marker at 0.0 ms, by both readings and through AVFoundation. It relies on the video streams matching exactly and starting at zero; if they don't, use `join_clips.sh`.

## Check a joined file

Three checks catch most bad joins without test clips:

* **Durations.** `ffprobe -v error -show_entries stream=codec_type,duration -of compact joined.mp4` should show video and audio within a frame of each other and close to the sum of the clips. A 41-second result from 26 seconds of clips is the clearest sign of a broken join.
* **Frame count.** `ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of compact joined.mp4` decodes every frame rather than trusting the header ([ffprobe documentation](https://ffmpeg.org/ffprobe.html#Main-options)). Compare it with the frames you expected.
* **Held frames.** `./inspect_clips.sh --gaps joined.mp4` lists frames whose own duration is more than 1.2 times the median, with their times, last frame included. It is a heuristic: in a file where 30 fps frames are the majority it can also list ordinary 24 fps frames, and a freeze made of repeated frames at a steady rate has normal durations, so check those by eye.

FFmpeg 9.0 removed `-vsync` ([FFmpeg commit](https://github.com/FFmpeg/FFmpeg/commit/927ffd0930fa3f7a948d4ed30c67bd518f9549ce)); FFmpeg 9.0.2 answers "Unrecognized option 'vsync'". Use `-fps_mode cfr`, `vfr` or `passthrough` instead ([ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Advanced-options)). To measure sync in your own pipeline, run its join on the test clips from the download and read the result with `measure.py`.

## Frequently asked questions

**How do I concat videos with FFmpeg when some have no audio?** Give each silent clip a silent track of its own length first, from `-f lavfi -i anullsrc=r=48000:cl=stereo` trimmed with `atrim=end_sample=`. The concat filter needs the same number of audio streams in every segment ([FFmpeg filters documentation](https://ffmpeg.org/ffmpeg-filters.html#concat)) and otherwise stops with "Stream specifier ':a' ... matches no streams."

**Why does FFmpeg concat print "Non-monotonic DTS"?** A packet from the next file had a decoding timestamp earlier than one already written, and FFmpeg moved it, warning that "this may result in incorrect timestamps in the output file" ([ffmpeg\_mux.c in FFmpeg 9.0.2](https://github.com/FFmpeg/FFmpeg/blob/n9.0.2/fftools/ffmpeg_mux.c#L180)). In our six same-model runs, Veo 3.1 Fast, Kling 3 Pro and Wan 2.7 produced one warning per join on the audio stream; Sora 2, Seedance 2.0 and Hailuo 2.3 produced none. The five mismatched clips produced 379 warnings. The warning does not say which property differs; run `inspect_clips.sh` on the inputs.

**Can I concat videos with different frame rates without re-encoding?** Sometimes, but not reliably with clips like these. The concat demuxer needs the same time base in every file ([FFmpeg formats documentation](https://ffmpeg.org/ffmpeg-formats.html#concat)), and in our mixed join the 30 fps clip played at 80% speed. Remuxing the 24 and 30 fps test videos, without audio, to MPEG-TS, which gives both the same time base, did let them stream-copy into one clean variable-frame-rate MP4. With audio and different sizes in the mix, conforming is the dependable route.

**Why is the joined video longer than the clips added together?** Usually because each clip's audio runs longer than its video, and the next clip starts after the longer stream, adding a held frame per join. Much larger errors come from mismatched formats, as in our 41.2 seconds from 26.1, or from a file whose stored duration is wrong or truncated, which the concat demuxer documentation names as a cause of artifacts ([FFmpeg formats documentation](https://ffmpeg.org/ffmpeg-formats.html#concat)).

**How do I replace the clips' sound with one music track?** Join first, then map the joined video with the new audio, padding the music with silence so the picture sets the length: `ffmpeg -i joined.mp4 -i music.wav -map 0:v -map 1:a -c:v copy -af apad -c:a aac -b:a 192k -shortest final.mp4`. `-shortest` finishes when the shortest stream ends ([ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Advanced-options)), and `apad` makes the music never the shortest, so a 3-second or a 40-second track both gave a file as long as our 26.083-second join, with the audio ending within a frame of the video.


## Voyager is a desktop AI agent for creative work

Describe what you want to make; Voyager plans it, uses your apps and tools, and keeps everything editable.

[Sign up](/sign-up)