Short answer: if every clip has identical stream parameters, use the concat demuxer with -c copy. It is instant and lossless. If anything differs (resolution, frame rate, SAR, pixel format, audio sample rate or channel count), use the concat filter and normalize each input first. The trap is that the demuxer does not refuse mismatched clips: it writes a file whose header looks fine while half the frames have a different size. This post shows that with real clips, gives a small Python wrapper that picks the right path, and pins the behaviour down with 15 pytest checks.
The test material is six real clips generated with PixVerse V6 (public examples from Voor's PixVerse V6 page): a 1920×1080 night market, a 720×1280 vertical underwater shot, a 1280×560 multi-shot bike ride and two 960×960 fox clips. All are H.264 High + AAC at 24 fps. That sounds compatible. It is not.

Real clips in, one geometry out: the vertical clip is pillarboxed, the 21:9 clip letterboxed, and the durations add up exactly.
1. The two ways FFmpeg concatenates
concat demuxer (-f concat) | concat filter (-filter_complex concat) | |
|---|---|---|
| Re-encodes | No, with -c copy | Yes, once |
| Speed on these clips | 0.06 s for two 5 s clips | 2.2 s for 18 s of output (CRF 20, preset medium) |
| Requires matching streams | Yes, all of them | No, if you scale/pad/resample first |
| Fails loudly on mismatch | No | Yes (filter graph errors) |
The demuxer needs a list file:
printf "file '%s'\n" "$PWD/a.mp4" "$PWD/b.mp4" > list.txt ffmpeg -f concat -safe 0 -i list.txt -c copy -movflags +faststart merged.mp4 -safe 0 allows absolute paths. Inside the list, a single quote in a filename must be written as '\''.
2. What stream copy does with mismatched clips
Merge the 1920×1080 night market and the 720×1280 vertical clip with -c copy and ask ffprobe about the stream:
ffprobe -v error -show_entries stream=width,height,nb_frames -of csv=p=0 naive.mp4 # 1920,1080,242 ffmpeg -v error -i naive.mp4 -f null - # prints nothing: zero decode errors Everything looks fine. Now look at the frames instead of the header:
ffprobe -v error -select_streams v:0 -show_entries frame=width,height -of csv=p=0 naive.mp4 | sort | uniq -c # 121 1920,1080 # 121 720,1280 The second half is still 720×1280. The muxer simply concatenated two H.264 streams with different parameter sets. What a player does with that is up to the player; nothing in the pipeline warned you.

The header says 1920×1080. The frames say otherwise.
Even the two fox clips, which share size, codec and frame rate, are not copy-safe: one has 32,000 Hz audio and the other 44,100 Hz.
3. Check compatibility before you copy
Compare the fields that matter against the first clip and refuse stream copy if any differ:
COPY_KEYS = ("vcodec", "profile", "width", "height", "pix_fmt", "fps", "sar", "acodec", "sample_rate", "channels") def copy_mismatches(paths) -> list[str]: """Human-readable list of parameters that differ from the first clip.""" infos = [probe(p) for p in paths] first = infos[0] problems = [] for p, info in zip(paths[1:], infos[1:]): for k in COPY_KEYS: if info[k] != first[k]: problems.append(f"{Path(p).name}: {k}={info[k]} (first clip has {first[k]})") return problems On the real clips:
copy_mismatches([fox, extend]) -> ["extend-snow-fox.mp4: sample_rate=44100 (first clip has 32000)"] copy_mismatches([market, kelp]) -> ["t2v-kelp-diver.mp4: width=720 (first clip has 1920)", "t2v-kelp-diver.mp4: height=1280 (first clip has 1080)"] probe() is a thin wrapper around ffprobe -show_streams -show_format -of json:
def probe(path: str | Path) -> dict: out = subprocess.run( [FFPROBE, "-v", "error", "-show_format", "-show_streams", "-of", "json", str(path)], check=True, capture_output=True, text=True).stdout data = json.loads(out) v = next(s for s in data["streams"] if s["codec_type"] == "video") a = next((s for s in data["streams"] if s["codec_type"] == "audio"), None) return { "duration": float(data["format"]["duration"]), "vduration": float(v.get("duration", data["format"]["duration"])), "aduration": float(a["duration"]) if a and "duration" in a else None, "vcodec": v["codec_name"], "profile": v.get("profile"), "width": int(v["width"]), "height": int(v["height"]), "pix_fmt": v.get("pix_fmt"), "fps": str(Fraction(v["r_frame_rate"])), "sar": v.get("sample_aspect_ratio", "1:1"), "frames": int(v["nb_frames"]) if v.get("nb_frames") else None, "acodec": a["codec_name"] if a else None, "sample_rate": int(a["sample_rate"]) if a else None, "channels": int(a["channels"]) if a else None, } concat_copy() writes the list file shown in section 1 (with the quote escaping) and runs the -c copy command.
4. Normalize, then concatenate with the filter
Each input gets the same geometry and timing before concat sees it. force_original_aspect_ratio=decrease plus pad letterboxes or pillarboxes instead of stretching, setsar=1 removes odd pixel aspect ratios, fps fixes the frame rate, and aresample + aformat put every audio track on one rate and layout. A clip with no audio gets exactly as much silence as it has video, otherwise concat refuses the graph.
def concat_normalize(paths, dst, width=1920, height=1080, fps=24, sample_rate=48000, crf=20, preset="medium") -> dict: infos = [probe(p) for p in paths] args, chains, pads = [], [], [] for i, (p, info) in enumerate(zip(paths, infos)): args += ["-i", str(p)] n = len(paths) for i, info in enumerate(infos): chains.append( f"[{i}:v]scale={width}:{height}:force_original_aspect_ratio=decrease," f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1," f"fps={fps},format=yuv420p[v{i}]") if info["acodec"]: chains.append(f"[{i}:a]aresample={sample_rate}," f"aformat=sample_fmts=fltp:channel_layouts=stereo[a{i}]") else: # silent clip: generate exactly as much silence as it has video chains.append(f"anullsrc=r={sample_rate}:cl=stereo," f"atrim=duration={info['vduration']:.6f}[a{i}]") pads.append(f"[v{i}][a{i}]") graph = ";".join(chains) + ";" + "".join(pads) + f"concat=n={n}:v=1:a=1[v][a]" subprocess.run([FFMPEG, "-v", "error", "-y", *args, "-filter_complex", graph, "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", str(dst)], check=True) return probe(dst) The three mixed clips go in as 1920×1080, 720×1280 and 1280×560 and come out as one 1920×1080, 24 fps, SAR 1:1 file: 435 frames, all 1920×1080, video duration 18.125 s, exactly the sum of the inputs, audio 18.073 s at 48 kHz.
Finally, pick the path automatically:
def merge(paths, dst, **kw): if not copy_mismatches(paths): return "copy", concat_copy(paths, dst) return "normalize", concat_normalize(paths, dst, **kw) 5. Tests that pin the behaviour
The suite runs against the real fixtures (15 passed in 7.5 s, pytest 9.2, FFmpeg 9.0.1):
def test_naive_copy_of_mismatched_clips_hides_a_resolution_change(tmp_path): out = m.concat_copy([MARKET, KELP], tmp_path / "naive.mp4") assert (out["width"], out["height"]) == (1920, 1080) # the header looks fine... assert frame_sizes(tmp_path / "naive.mp4") == {(1920, 1080), (720, 1280)} # ...the frames are not def test_vertical_clip_is_pillarboxed_not_stretched(merged): dst, _ = merged t = m.probe(MARKET)["vduration"] + 2.5 # middle of the kelp-diver segment assert column_mean(dst, t, 0, 40) < 6 # left bar is black assert column_mean(dst, t, 152, 192) < 6 # right bar is black assert column_mean(dst, t, 80, 112) > 20 # picture in the middle def test_audio_stays_in_sync(merged): _, out = merged assert out["sample_rate"] == 48000 and out["channels"] == 2 assert abs(out["aduration"] - out["vduration"]) < 0.1 The other tests cover: identical clips report no mismatch, sample rate alone blocks copy, copy of identical clips keeps every frame, merge() picks copy only when safe, output has one geometry, duration equals the sum of inputs, the 21:9 clip is letterboxed, segments stay in order, a silent clip gets matching silence, and a filename with an apostrophe and parentheses survives the list file.
6. Pitfalls checklist
- Never trust the stream header after
-c copy. Check per-frame sizes with-show_entries frame=width,heightif the inputs came from different sources. - Audio counts. Different sample rates or channel layouts break copy-merges just as resolution does.
- Pick the target size deliberately. Letterboxing a 9:16 clip into 16:9 wastes most of the frame. For a vertical deliverable, set
width=1080, height=1920instead. - Variable frame rate phone clips: the
fpsfilter fixes the output rate; check lip-sync on talking clips afterwards. - Re-encoding is a quality step. Use CRF 18–20 for an intermediate, then compress the final file separately.
- Timestamps: if a stream-copied merge plays with a pause at the joins, the inputs probably start with non-zero timestamps; re-encode them or remux each with
-avoid_negative_ts make_zerofirst.
Getting test clips
To reproduce mismatches like these you need clips with different shapes and audio. The fixtures here are PixVerse V6 outputs. If you want fresh ones, the PixVerse V6 generator on Voor lets you set aspect ratio (8 shapes from 21:9 to 9:16), resolution (360p to 1080p), duration and audio per clip. The form quoted 79 credits for a 5 s 16:9 720p clip without audio and 105 with audio on October 31, 2026. Generate a 16:9 and a 9:16 clip with audio and you have a ready-made test pair for copy_mismatches(): make a mismatched test pair with PixVerse V6.
