Why naive ffmpeg concat desyncs a highlight reel
The obvious way to build a highlight reel — cut clips with -c copy, then join them with the concat demuxer — failed in three different ways on a real iPhone recording. This is what went wrong and what the tool does instead.
The symptoms
- The finished reel's video started 1.01 s after its audio and never caught up.
- ffmpeg printed
Non-monotonic DTSat every join. - HEVC decoding failed after joins with
Could not find ref with POC, showing corrupt frames.
Why it happens
Stream-copied cuts must start on a keyframe, but the audio packet nearest that keyframe starts a few milliseconds earlier or later. Each clip therefore has slightly different audio and video start times. The concat demuxer stitches timestamps, not samples, so those offsets accumulate, and the first clip's offset (here, a 1.01 s keyframe gap) becomes permanent. HEVC open-GOP streams also reference frames from before the cut, which do not exist in the joined file.
The fix: trim → concat → one encode
ffmpeg -i source.MOV -filter_complex \
"[0:v]trim=start=A:end=B,setpts=PTS-STARTPTS[v0];[0:a]atrim=start=A:end=B,asetpts=PTS-STARTPTS[a0];... \
[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \
-c:v libx265 -crf 19 -color_primaries bt2020 -color_trc arib-std-b67 -colorspace bt2020nc reel.mp4Every segment is trimmed from the original source with sample-accurate timestamps reset to zero, concatenated in the filter graph, and encoded once. No join ever sees a keyframe boundary, so there is nothing to drift.
Then verify, every time
Tennis Evolution checks each output: clean full decode, video and audio both starting at 0 s, matching durations, non-black first and last frames, non-silent audio. A failing check exits non-zero, so a script or an agent cannot mistake a broken reel for a finished one.
FAQ
Can I still do a lossless join?
Yes, `tennis-evolution concat` exists as an escape hatch, but it reports the timestamp problems instead of hiding them.