DEV Community

Mason K
Mason K

Posted on

One live event, two recordings: stitching dropped streams with FFmpeg, and when to stop

📦 Code: github.com/USER/live-gap-stitch (replace before publishing)

TL;DR

A brief disconnect during a live event leaves you with two recordings and a gap that no file admits to. We'll join the pieces with FFmpeg's concat demuxer, insert a labelled gap card so the jump cut isn't a lie, and then look at what changes when the platform handles reconnects for you. Needs FFmpeg 6+ and bash.

🎯 What happened

Host's laptop switches wifi mid-event. The encoder loses the ingest connection. The host reconnects and carries on. Server side, the disconnect looked like "broadcaster hung up", so the recorder closed one file and opened another. Now your "recording" is two files and the viewers saw a stalled player.

We'll fix the artifact first (the recording), then talk about the cause (session identity).

1. Reproduce it with two test segments 🛠️

No live encoder needed. Make two FLV files shaped like what an RTMP recorder leaves behind:

ffmpeg -v error -y -f lavfi -i testsrc2=s=640x360:r=30:d=6 \
  -f lavfi -i sine=frequency=440:d=6 -c:v libx264 -g 60 -c:a aac -f flv seg1.flv
ffmpeg -v error -y -f lavfi -i testsrc2=s=640x360:r=30:d=5 \
  -f lavfi -i sine=frequency=660:d=5 -c:v libx264 -g 60 -c:a aac -f flv seg2.flv

for f in seg1.flv seg2.flv; do
  ffprobe -v error -show_entries format=start_time,duration -of csv=p=0 $f
done
Enter fullscreen mode Exit fullscreen mode
0.044000,6.067000
0.044000,5.067000
Enter fullscreen mode Exit fullscreen mode

Notice both segments start at about zero. Each recording has its own clock, so nothing in the files tells you how long the host was gone.

2. The naive join

printf "file 'seg1.flv'\nfile 'seg2.flv'\n" > list.txt
ffmpeg -hide_banner -v error -y -f concat -safe 0 -i list.txt -c copy joined_copy.mp4
ffprobe -v error -show_entries format=duration -of csv=p=0 joined_copy.mp4
Enter fullscreen mode Exit fullscreen mode
11.105306
Enter fullscreen mode Exit fullscreen mode

That's 6.067 + 5.067 give or take container rounding. It works, and it hides the gap completely. A reviewer watching the output sees a jump cut with no explanation.

⚠️ Note: -c copy with the concat demuxer only behaves when both files share codec parameters. When encoders restart with different settings, you'll get glitches or errors. That's why the next step re-encodes to a common format.

3. Join with a gap card ✅

You need the real gap length. Record wall-clock timestamps in your ingest webhook handler (disconnect time, reconnect time) and compute the difference. Then:

#!/usr/bin/env bash
# join_with_slate.sh seg1.flv seg2.flv GAP_SECONDS out.mp4
set -euo pipefail
A="$1"; B="$2"; GAP="$3"; OUT="$4"
W=640; H=360; FPS=30
# 1) slate: plain dark card for the length of the gap, silent AAC
ffmpeg -hide_banner -v error -y \
  -f lavfi -i "color=c=0x111111:s=${W}x${H}:r=${FPS}:d=${GAP}" \
  -f lavfi -i "anullsrc=r=44100:cl=stereo" -t "$GAP" \
  -c:v libx264 -pix_fmt yuv420p -g 60 -c:a aac -ar 44100 -ac 2 slate.mp4
# 2) normalize both segments to the same params, then concat
for f in "$A" "$B"; do
  ffmpeg -hide_banner -v error -y -i "$f" -vf "scale=${W}:${H},fps=${FPS},format=yuv420p" \
    -c:v libx264 -g 60 -c:a aac -ar 44100 -ac 2 "norm_$(basename "$f").mp4"
done
printf "file 'norm_%s.mp4'\nfile 'slate.mp4'\nfile 'norm_%s.mp4'\n" "$(basename "$A")" "$(basename "$B")" > list.txt
ffmpeg -hide_banner -v error -y -f concat -safe 0 -i list.txt -c copy "$OUT"
Enter fullscreen mode Exit fullscreen mode
$ ./join_with_slate.sh seg1.flv seg2.flv 4 joined_slate.mp4
$ ffprobe -v error -show_entries format=duration -of csv=p=0 joined_slate.mp4
15.098231
$ ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate,sample_rate -of csv joined_slate.mp4
stream,h264,640,360,30/1
stream,aac,44100,0/0
Enter fullscreen mode Exit fullscreen mode

6.067 + 4 + 5.067 is about 15.13, and we got 15.10. Close enough for a file whose parts are rounded to frame boundaries. (sample_rate for the video stream prints 0/0, which is just ffprobe's way of saying "not applicable".)

💡 Tip: Want text on the card ("Stream reconnecting")? Add a drawtext filter. It needs an FFmpeg built with freetype and a font path, so I left it out to keep this runnable everywhere.

4. What you now own 😅

Look at the checklist this created:

  • Capture disconnect and reconnect times reliably (webhooks can arrive late or out of order).
  • Decide what viewers see live during the gap. The slate only fixes the recording.
  • Keep both sessions under one event ID, one playback URL, one analytics entry.
  • Handle a host who drops twice, drops in the last seconds, or returns with a different encoder profile.
  • Run the stitch job, and make sure someone notices when it needs to run.

None of this is hard. It's just a state machine you didn't plan to write. The root cause is that the stream session is tied to the connection.

5. Moving the problem to the platform

The fix at the cause is a session that survives a short absence of its source. FastPix's live product does this with a reconnect window: it keeps the stream session active during short disconnects, so the broadcaster comes back into the same session. It also saves live streams as on-demand assets automatically (live-to-VOD), so there's no separate recording pipeline to run. Ingest is RTMPS or SRT, delivery is LL-HLS.

Creating a stream is a POST https://api.fastpix.com/v1/live/streams with Basic auth (Access Token ID as username, Secret Key as password). I'm deliberately not pasting a request body here; copy it from the quickstart so it matches the current reference. Playback is a standard HLS URL of the form https://stream.fastpix.com/<playbackId>.m3u8, so any HLS player works.

Things I did not verify, so check them before you promise anything to a customer:

  • How long a disconnect the reconnect window tolerates.
  • Exactly which webhook events fire for a live-to-VOD asset.

Honest trade-offs: FastPix is API-first, so if you wanted a no-code event dashboard you'll build that front end yourself. And if you have strict regional data residency needs (EU-only, India-only), confirm region availability first, since some regions roll out on request. The same patterns above apply on any platform that offers reconnect handling, so the design lesson transfers.

What's next

  • Test with a deliberate disconnect: kill your encoder for a few seconds and see how many sessions and recordings you end up with.
  • Add the gap length to your analytics so "watch time" isn't inflated by a slate.
  • If you stay DIY, store the stitched file under the original event ID, never a new one.

What's the weirdest reconnect bug you've hit? #video #ffmpeg #streaming #tutorial

Top comments (0)