Putting two video files end to end sounds like the cheapest operation in the world, and for about five minutes I believed it. Then I joined a 1280x720 clip at 30fps to a 640x480 clip at 25fps using the fastest method available, which copies the streams and rewrites nothing. It produced a file. The file claimed to be eleven point nine nine six seconds long when the two clips add up to eleven, the container said 1280x720 while the second half of the stream was 640x480, and the muxer printed a warning about rewritten timestamps for every frame of the second clip. Everything about joining video follows from why that happens.
TL;DR: The cheap join only works when both files already agree on codec, resolution, frame rate and timebase, which footage from two devices never does. The honest route decodes both and encodes one new file, which is slower and produces something that plays. Measured here: the copy method reported 11.996 seconds for 11 seconds of footage and declared the wrong resolution; the re-encode produced exactly 11.000 seconds at a single consistent 1280x720 in 0.63 seconds. Clips of different sizes have to be fitted into one frame first, or the join is refused outright. The video editor does all of it in the browser, up to eight clips and thirty minutes.
Why can two clips not just be stuck together?
Because a video file is not a stream of pictures with a header on it. It is a container holding encoded streams, and the encoding carries state.
H.264 compresses by describing most frames as differences from other frames. A keyframe stands alone; everything after it is defined relative to what came before, until the next keyframe. The decoder also needs the stream's parameter sets, which describe the resolution, the colour format and the profile, and it holds a timebase that converts the numbers on each packet into seconds.
Two files made by two devices agree on none of that. Different resolution means different parameter sets. Different frame rate usually means a different timebase. Even two clips from the same phone, recorded a year apart, can differ because the camera app changed its encoder settings.
Concatenating the bytes gives you a stream whose decoder is told one thing in the header and handed another halfway through. Sometimes a player copes. Often it shows the first clip and stops, or plays the second one as coloured noise.
What does the fast method produce?
A file, a pile of warnings and a duration that is wrong.
ffmpeg's concat demuxer with stream copying is the method every "just join them" answer online recommends, and it is genuinely the right tool when the inputs match. Pointed at my two mismatched clips it exits successfully, and here is what it hands back:
| Measure | Expected | Produced |
|---|---|---|
| Duration | 11.000 s | 11.996 s |
| Declared resolution | one value, correct | 1280x720 for a stream that becomes 640x480 |
| Timestamp warnings | none | one per frame of the second clip |
The warnings all say the same thing:
Non-monotonic DTS; previous: 90981, current: 76133; changing to 90982.
This may result in incorrect timestamps in the output file.
The second clip's timestamps restart from zero, as they should inside their own file, and the muxer will not write a stream whose timestamps go backwards. So it invents new ones, one frame at a time, and the accumulated difference between the real timing and the invented timing is where the extra second comes from.
ffmpeg can still decode both halves afterwards, because ffmpeg is unusually tolerant. A browser is not. This is the file that plays fine on your machine and shows a black rectangle for a viewer.
What does joining through a filter graph cost, and what does it buy?
It costs a full decode and encode. It buys a file that is correct.
Instead of copying streams, each input is decoded into frames, the frames are trimmed and joined inside a filter graph, and one new stream is encoded from the result. Every frame passes through a decoder and an encoder, which is real work.
What it buys is that the output has one codec, one resolution, one frame rate and one continuous set of timestamps, because it was all encoded at once from scratch. On the same two clips:
| Measure | Copy method | Filter graph |
|---|---|---|
| Duration | 11.996 s | 11.000 s |
| Resolution | declared 1280x720, becomes 640x480 | 1280x720 throughout |
| Frames | 305 | 305 |
| Export time | instant | 0.63 s |
| Size | smaller | 2.1 MB |
0.63 seconds of work for 11 seconds of output, on a desktop running ffmpeg natively. In a browser tab through WebAssembly it is several times slower, which is the honest trade and the reason the clip count is capped.
Why does each clip need its timestamps rebased?
Because concatenation in a filter graph joins streams, and a stream's timestamps are absolute.
Each clip's frames arrive carrying times measured from the start of their own file. Trim a section from the middle of a clip and those frames still say "this happens at 42 seconds". Join them to a clip that ends at 6 seconds and you have a stream that jumps from 6 to 42, with 36 seconds of nothing in between.
setpts=PTS-STARTPTS subtracts each clip's own start from every frame, so every clip begins at zero before it is joined. The audio equivalent is asetpts. Without them the output plays the first clip, then a long freeze, then the second. It is the single most common mistake in hand-written ffmpeg join commands, and it is silent: the file is valid and the duration is just wrong.
What happens when the clips are different sizes?
The join is refused, and until recently that refusal was the whole story here.
The concat filter compares its inputs and will not run if the video dimensions or the sample aspect ratio disagree:
Input link in0:v0 parameters (size 640x480, SAR 1:1) do not match the
corresponding output link in0:v0 parameters (1280x720, SAR 1:1)
Failed to configure output pad on Parsed_concat_8
That is an abort, not a warning: no file is produced. Two phones, or a phone clip and a screen recording, is exactly the case that triggers it, and it is the ordinary reason someone joins clips at all.
Audio needs no such help, which is worth knowing because it is counter-intuitive. The same graph joined a 44.1 kHz mono track to a 48 kHz stereo one without complaint and produced an exact 11.000 second file. Only the video side is strict.
The fix is to settle one frame for the whole timeline and fit every clip into it before joining. The height comes from the output resolution you picked, or from the first clip when you keep the source resolution, and the width follows that clip's aspect ratio so a timeline of portrait footage stays portrait. Each clip is then scaled to fit inside that frame and padded to fill it:
scale=1280:720:force_original_aspect_ratio=decrease,
pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1
Fitting rather than filling means a clip of a different shape is letterboxed instead of stretched, which is the lesser of two evils when a 4:3 clip meets a 16:9 timeline. setsar=1 is not decoration: the filter compares sample aspect ratio as well as pixel dimensions, so two clips can be the same size and still be rejected.
Both dimensions are rounded to even numbers, because H.264 in 4:2:0 stores colour at half resolution in each direction and cannot encode an odd width or height.
Why only eight clips and thirty minutes?
Because each clip costs a decode pass inside a browser tab, and the tab has a fixed amount of memory.
Eight inputs means eight decoders running against the same filter graph, plus the encoder, plus every source file held in memory because there is no disk to stream from. Thirty minutes of output is the point past which the encode takes long enough that people assume the page has hung.
Neither limit is a law of the format. Both are the line past which the browser build stops being pleasant, and they are stated up front rather than discovered when a tab crashes forty minutes into an export. A clip shorter than a tenth of a second is also refused, because below that it contributes no frames and ffmpeg errors rather than warns.
For anything larger, this is the wrong tool and a desktop editor is the right one.
How is trimming here different from the trimmer?
Trimming inside a join is frame accurate, and it costs nothing extra.
The dedicated video trimmer offers a fast mode that copies the stream and can only cut on a keyframe, which is why the start of a trim sometimes lands up to ten seconds early. Its guide covers why in detail.
That trade does not exist here, because the export is re-encoding everything anyway. The trim happens inside the filter graph, on decoded frames, so a cut at 4.3 seconds is a cut at 4.3 seconds. You get frame accuracy free, as a side effect of the cost you are already paying.
So if you want one clip trimmed and nothing else, the trimmer's fast mode is far quicker. If you are joining anything, trim here.
What does a speed change do to the audio?
It has to be handled separately, and the arithmetic is not the same as for video.
Video speed is a timestamp operation: setpts=PTS/2 halves every frame's presentation time and the frames themselves are untouched. Audio cannot work that way, because playing samples twice as fast raises the pitch an octave. The atempo filter resamples instead, keeping the pitch and changing the duration.
atempo accepted only 0.5 to 2.0 in a single pass for most of its life, so a 4x change is built as two 2x passes and a 0.25x change as two 0.5x passes. Current builds have raised the ceiling, and ffmpeg 8.1 reports a range of 0.5 to 100. Measured on one clip, a single atempo=4 and a chained atempo=2,atempo=2 landed 11 milliseconds apart.
The chain is still what gets built, because the WebAssembly core is fetched at runtime rather than pinned to a version, and multiplying passes is correct on every build that has ever shipped the filter. The speed range offered, 0.25x to 4x, is where the result is still an edit rather than a novelty.
MP4 or WebM?
MP4 unless you have a specific reason, and the reason is almost always a licence rather than a quality argument.
| MP4 | WebM | |
|---|---|---|
| Video codec | H.264 | VP9 |
| Audio codec | AAC | Opus |
| Plays in | everything | browsers, most modern players |
| Encode speed in a tab | faster | considerably slower |
| Size at similar quality | larger | smaller |
VP9 produces smaller files for the same perceived quality, and it is royalty free, which matters to some projects and not at all to others. ffmpeg's own VP9 encoding guide is blunt about the speed cost. What it is not is fast. The WebM path here runs at a higher CRF with a realtime deadline and cpu-used 5, which are settings a desktop encode would consider careless, because without them a VP9 encode in a tab takes long enough to feel broken.
The MP4 path uses -preset veryfast at CRF 23 with yuv420p, plus -movflags +faststart, which moves the index to the front of the file so it plays while downloading rather than only after.
How big will the export be, and how long will it take?
Size is predictable from the resolution. Time is not predictable from anything.
Because the export always re-encodes, the source file's size tells you very little about the result. The estimate shown comes from the output height instead: roughly 5 Mbps at 1080p, 2.5 at 720p, 1.2 below that, with WebM landing around 70% of the MP4 figure and audio adding 128 kbps unless muted.
That is an estimate and is labelled as one, because content matters more than any formula. A static interview shot and a handheld pan through trees at the same resolution and the same CRF differ by several times, since CRF targets a quality level and spends whatever bitrate that takes.
Time depends on your machine, the resolution, the number of clips and whether the tab has anything else to do. The native ffmpeg run above managed 11 seconds of output in 0.63 seconds; expect the browser to be several times slower than that, and expect WebM to be slower again.
What this does not do
It joins, trims, reorders, scales, changes speed and drops audio. That is the whole feature set, and the gap between it and a video editor is worth being clear about.
There are no transitions, no titles, no music track, no colour correction, no cropping, no per-clip volume, no keyframed anything, no preview of the finished timeline. Each clip is trimmed and placed, one set of output settings applies to all of them, and the result is encoded.
That covers a real and common job: several recordings that need to become one file, trimmed to the useful parts, in the right order, at a size something will accept. For anything involving a creative decision, use an editor.
What order do the clips go in, and can you change it?
The order you added them, and yes, with the up and down buttons on each clip.
Reordering is a list operation rather than a video one: moving a clip changes which input index it occupies in the filter graph, and nothing is re-read or re-decoded until the export runs. So a move is instant however large the files are, and you can rearrange a timeline as many times as you like for free.
Buttons rather than drag and drop is a deliberate choice for a list of at most eight items. Each one carries a label naming the clip it moves, so the order is changeable by keyboard and readable by a screen reader, neither of which a drag handle gives you without a good deal of extra work.
The thing worth checking before exporting is the direction of the sequence. Several recordings of the same event usually arrive from a file picker in alphabetical order, which is the order a camera's filenames happen to produce and not always the order in which the recordings were made.
Why does this run in the browser?
Because the footage is usually the kind you would rather not upload, and the job needs no server.
Every operation here is ffmpeg, and ffmpeg compiles to WebAssembly. The files are read from your disk into the tab, decoded there, joined there, encoded there, and handed back as a download. Nothing is uploaded, no queue holds your footage, and there is no copy on a machine you do not control.
The clips people join are interviews, consultations, family recordings, screen captures of internal software, drafts under embargo. The usual alternative uploads several hundred megabytes to a free service, converts it on hardware you know nothing about, and keeps it under terms nobody reads.
The costs are the eight clip and thirty minute ceilings, and an encode that is slower than a desktop one. Both are stated before you start, which is the trade worth taking.



