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.

Three real PixVerse V6 clips at 1920x1080, 720x1280 and 1280x560 normalized by the FFmpeg concat filter into one 1920x1080 24 fps timeline of 18.125 seconds
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.

Concat demuxer stream copy of a 1920x1080 and a 720x1280 clip: ffprobe header says 1920x1080 with zero decode errors, but 121 frames are 1920x1080 and 121 are 720x1280
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,height if 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=1920 instead.
  • Variable frame rate phone clips: the fps filter 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_zero first.

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.