
Extended an AI video and the join stutters? No frame repeats — the motion breaks for half a second
Chain takes from the last frame of the one before and a plain concat sticks. We measured the cat-and-paper-boat seam frame by frame: nothing repeats, the motion stops for half a second.
Short version: a plain join does stutter — just not the way you think.
Here is what happened. The cat-and-paper-boat clip (the whole story is in Making a video with AI: a paper boat comes for a stray cat) was generated in three takes, each one seeded with the last frame of the take before it. Seedance 2.5 honours that very well; the compositions on both sides of a seam are nearly glued together. The director watched 02 and 03 and said: "The first frames of 03 look like they overlap the tail of 02 — see how to handle that when you cut."
So we really did look, frame by frame. Verdict: no overlap. Not one frame repeats. And yet it does stick.
What "stuck" actually is: half a second of dead water
Take the first 240 frames of 03 and match each one against the last 240 frames of 02. If the timelines truly overlapped, the correspondence would draw a slope: frame 10 of 03 looks like frame −30 of 02, frame 20 like frame −20, and so on. What we got was a flat line. Every early frame of 03 looks most like the last frame of 02, and the similarity only decays over time. Nothing is re-enacted.
What is really going on is obvious once you watch what each take is doing:
- End of 02: the paper boat spends about five seconds unfolding into a rainbow six-pointed star.
- Start of 03: the star folds back into a boat, the boat lifts off, and the cat follows it into the street.
The fold is not a rewind; it is the wind-up before lift-off. The problem is the first 0.5 s of 03, which barely moves. Generated video warming up from a reference first frame does this all the time — we call it slow-start dead water. Join the takes raw and the audience sees: star opens to the top, freezes for half a second, then folds. That freeze is the entire truth behind "it stuck" and "it repeated".

The same 02→03 seam. Top row, raw join: 03 barely moves for about half a second. Bottom row: 30 frames cut plus a 0.1 s dissolve. The curve underneath is per-frame motion; the half second where the red line drops into the shaded band is the dead water.
Same second of footage, looping at half speed. Left is the raw join, which pauses after the seam; right is the final cut, which keeps going. At half speed that half second becomes a whole one, and the eye cannot miss it.
Before you cut: make sure the footage is not lying to you
We had not planned to write this section. One sentence forced it: "I thought the originals were 60 fps — why did it come out 30?"
It had not. The file was true 60. The 30 came from CapCut, which reports its project timeline rate, not the footage. But that sentence made us go back and measure all three takes, and the measuring found two real problems.
Method first. A video has three "frame rates", and they are not the same thing:
| Frame rate | What it is | Who can change it |
|---|---|---|
| Container fps | The number in the mp4 header | Anyone, any tool, any value |
| Frame density | Frame count ÷ duration; the container really holds this many frames | Interpolation, repackaging |
| Effective fps | What actually moves once you discard frames nearly identical to the previous one | Only the pixels themselves |
Pack 30 fps content into a 60 fps container and the first two numbers dutifully say 60. Only the third one tells the truth.
You cannot detect padded frames by counting "frames with diff equal to zero". Once a duplicated frame is re-encoded it picks up compression noise; the diff lands between 0.02 and 0.15, and any absolute threshold misses it. We look at phase structure instead: within every P frames a fixed number do not move, and that periodic signature survives compression. Measured: padded footage shows an odd/even phase difference of 7.5×, real footage 1.1×. No judgement call needed.
The two things we found:
| Take | Frames | Near-still frames | Reading |
|---|---|---|---|
| cat_mobile_01 | 722 | 2 (0.3%) | clean 60 |
| cat_mobile_02 | 1020 | 137 (13.4%) | stalled frames; effective smoothness around 52 fps |
| cat_mobile_03 | 1082 | 1 (0.1%) | clean 60 |
The 13.4% of stalled frames in 02 always come in pairs, 8 to 12 frames apart, with normal frames on either side. That is not a frame-rate conversion (30→60 would give 50% duplicates with a diff of exactly zero); it is a random defect of one generation. Usually only one take in a batch catches it, and regenerating that take clears it. We did not regenerate, because 13% is hard to see in a moving shot (and because the boss's tokens are finite).
The second finding was more embarrassing. The landscape 03 turned out to be 854×480, 24 fps, 433 frames. It was the preview download. Generation platforms usually offer a preview and an HD file, and the names can be identical. Join the preview and you upscale 3.7× into mush, and 24 fps into a 60 fps timeline needs interpolation that cannot be recovered. Back to the platform, download again. That check is now the first step of our process, no exceptions.
Only three kinds of seam, and they are cut in opposite ways
Get the class wrong and the fix makes things worse, so classify before you cut:
| Type | Signature | Treatment |
|---|---|---|
| Different shot | The first frame of 03 is about as similar to every frame of 02 as two unrelated pictures | Hard cut; do not add a transition |
| Composition continuation | The first frame of 03 matches the last frame of 02, and the correspondence curve afterwards is a flat line | Cut the dead water, short dissolve |
| True overlap | The correspondence curve is a slope; 03 re-enacts 02 | Cut the re-enacted stretch |
The key is that correspondence curve, and it needs a control group: the median MAE of random pairings. Without it you cannot tell whether "MAE 11.6" is similar or not. In the portrait batch, the first frame of 03 against the last frame of 02 scores 11.6 with a control median of 46.6 — that is similar; the 01→02 seam sits around 50 across the board, the same as the control — that is a shot change, meant to be a hard cut, leave it alone.
A single-frame comparison cannot separate composition continuation from true overlap, and their cuts are opposites: one trims the head of the new take, the other trims the tail of the old one.
Pick the cut by ratio, not by absolute value
For a composition continuation the only decision is how many frames to cut. We use one rule:
Motion in the 0.6 s after the seam ÷ motion in the 0.6 s before it should approach 1.
Below 0.75 is dead water, the motion does not connect; above 1.4 is a lurch, you cut too much and the motion suddenly speeds up. The portrait numbers: 0.54 uncut, 1.45 with 30 frames cut.
This rule was earned, and not cheaply. The landscape footage looked identical to the portrait. Following the portrait logic we looked only at 03's own curve — "low then high", must be slow-start — and worked out a cut of 54 frames.
The answer was 0.
| Landscape 02→03 | No cut | Cut 30 | Cut 54 |
|---|---|---|---|
| After ÷ before | 0.93 | 0.99 | 1.59 |
The landscape 02 simply ends slowly (motion 0.48; the portrait is 0.83), so 03 opening at 0.45 is a match, not dead water; the climb to 2.0 afterwards is not "slow-start ending", it is the cat actually starting to follow the boat. Same curve, different baseline, opposite conclusion. Two batches that look the same needed 30 and 0; you cannot copy one onto the other. That one is on us.
Even at a cut of 0, keep a 0.1 s cross-dissolve. A continued take differs from the previous tail in texture and lighting (MAE usually 10 to 13), so a hard cut flashes; since the composition matches, the dissolve does not ghost.
Three FFmpeg landmines
The join is encoded in a single pass with nothing written to disk in between, so there is no second generation of quality loss. But the filter chain has three landmines, and they are landmines because they fail in unintuitive ways:
- The output of
concatmust go throughfps=60,settb=AVTBbeforexfade. Otherwise you getError reinitializing filters, a message that never mentions timebase — and each step runs fine on its own, only the chain dies. - Every audio stream needs
aformatto unify the sample format. Otherwise the AAC encoder returns-22 Invalid argumentand looks broken. - A wrong
xfadeoffsetdoes not error. FFmpeg exits 0 and puts the transition at the wrong time; you only find out on playback. The first two fail loudly and are easy to trace; this one fails silently. So the script reads durations withffprobe, computes the offset, and prints it.
Verify after cutting, and not just the headers
- Duration reconciliation: the output duration equals the sum of the takes minus the seconds cut minus the dissolve, within a few frames. Portrait: 12.033 + 17.020 + 18.033 − 0.5 − 0.1 = 46.49, actual 46.50.
- Seam frame grabs: pull a few frames either side of each seam and look at them side by side — no ghosting on the dissolve, continuity across the hard cut.
- Effective fps: measure the output again; it should match the sources. The portrait output has 2 duplicate frames in 2,789.
- Segment-wise audio: run
volumedetectat several points along the clip for silences and clipping. Its output is at info level;-v errorshows nothing.
Frame grabs and motion curves are not the same as having watched it play through. That sentence stays honest.
The tools are open source
The three scripts — footage QC, seam diagnosis, the join — and every rule above are in the public Curtoom video-clip-stitching skill, one line to install for Claude Code or Codex. Honestly, the whole thing started with the director saying "wait, I thought you could do this with ffmpeg?" — it was ffmpeg the entire way, no editor opened.
Storyboards govern what happens before generation; for writing panels so shots connect, see Use a simple storyboard to make every shot connect; for the panel-by-panel check of the eight-panel storyboard against the final video, see Does an AI video really read the storyboard?. This article only cares about the 0.5 s after you have the three takes in hand.
