Skip to content

Commit d0bb212

Browse files
author
DatanoiseTV
committed
fix(video): checkpoint on encoder reconfig + real PTS-derived segment duration
Two related issues with live RTMP video. 1. "Very old video then stops" When OBS reconfigures its encoder mid-session (resolution change, preset swap, restart) it re-sends the AVCDecoderConfigurationRecord with new SPS/PPS. Any bytes already in the 8 MB video ring buffer were encoded under the *old* parameters, and the cached keyframe offsets point into them. A listener that subscribed after the reconfig would replay those old frames with the new SPS/PPS active and decode blew up, then stalled. parseAVCConfig now detects that it already had SPS/PPS (i.e. this is a reconfig, not first init) and calls videoStream.CheckpointAtHead(), which marks the current Buffer.Head as a new MinListenerOffset and clears the keyframe index (Buffer.ResetKeyframes). serveStreamData bumps any new listener's start offset up to MinListenerOffset before seeking to the latest keyframe, so reconfigs no longer replay stale content. Stream.GetMinListenerOffset / new CheckpointAtHead encapsulate the read/write under the stream mutex. 2. "Buffering at 90%, 5s cache" The HLS ring previously recorded every segment with the configured SegmentDuration regardless of the actual wall time of the frames inside — a mismatch with OBS's keyframe interval made the player wait for phantom content that was never coming. segmentLoopFramed now derives segment duration from the span of PTS values it actually included (pesBatchRange returns first + last-end PTS across the audio and video batches) and pushes that into the ring. EXTINF is now accurate and TARGETDURATION adapts correctly via math.Ceil over the real durations.
1 parent 5ef7b18 commit d0bb212

5 files changed

Lines changed: 109 additions & 8 deletions

File tree

relay/buffer.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ func (cb *CircularBuffer) HeadOffset() int64 {
147147
return cb.Head
148148
}
149149

150+
// ResetKeyframes drops every recorded keyframe offset. Use when the
151+
// source's codec config changes mid-stream — old keyframe offsets point
152+
// at bytes the new decoder state can't interpret, so they must not be
153+
// handed out as safe starting points for new listeners.
154+
func (cb *CircularBuffer) ResetKeyframes() {
155+
cb.mu.Lock()
156+
defer cb.mu.Unlock()
157+
cb.kfHead = 0
158+
cb.kfCount = 0
159+
}
160+
150161
// FindNextPageBoundaryLocked holds the buffer's RWMutex for the duration of
151162
// the scan, so it's safe to use against a buffer that may be concurrently
152163
// written by a source goroutine. Returns the absolute offset of the next

relay/ingest_rtmp.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,13 @@ func (h *rtmpHandler) parseAVCConfig(data []byte) {
438438
if len(data) < 8 {
439439
return
440440
}
441+
// If we already had SPS/PPS from an earlier config, the publisher has
442+
// reconfigured its encoder (different resolution, profile, GOP size,
443+
// etc.). Bytes already in the video buffer were encoded under the
444+
// old parameters and would blow up any new listener's decoder once
445+
// the new SPS/PPS is active. Checkpoint the current Head so new
446+
// listeners only see bytes written from here on.
447+
reconfig := len(h.sps) > 0 || len(h.pps) > 0
441448
// AVCDecoderConfigurationRecord structure:
442449
// [0] configurationVersion
443450
// [1] AVCProfileIndication
@@ -492,11 +499,19 @@ func (h *rtmpHandler) parseAVCConfig(data []byte) {
492499
h.videoStream.StoreVideoHeaders(out)
493500
}
494501

502+
if reconfig && h.videoStream != nil {
503+
h.videoStream.CheckpointAtHead()
504+
logger.L.Infow("RTMP: Encoder reconfig — checkpointed video buffer",
505+
"mount", h.mount,
506+
)
507+
}
508+
495509
logger.L.Infow("RTMP: Parsed AVC config",
496510
"mount", h.mount,
497511
"sps_len", len(h.sps),
498512
"pps_len", len(h.pps),
499513
"nalu_len_size", h.naluLenSize,
514+
"reconfig", reconfig,
500515
)
501516
}
502517

relay/output_hls.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,18 +176,24 @@ func (h *HLSOutput) segmentLoopFramed(ctx context.Context, audio, video *Track)
176176
return
177177
}
178178
tsData := h.buildAVSegment(audioBatch, videoBatch, audioStreamType)
179-
// Use the first video PTS (or audio PTS as a fallback) as the
180-
// segment's nominal start so the ring records a sensible time.
181-
startPTS := int64(0)
182-
if len(videoBatch) > 0 {
183-
startPTS = videoBatch[0].pts
184-
} else if len(audioBatch) > 0 {
185-
startPTS = audioBatch[0].pts
179+
// Use the first PTS as the segment's nominal start, and derive
180+
// the segment duration from the actual span of PTS values we
181+
// included. Previously we always declared SegmentDuration
182+
// regardless of what the content contained, which made the
183+
// player buffer whenever keyframe interval didn't match — e.g.
184+
// an OBS keyframe every 5 s into a 4 s advertised window left
185+
// 1 s of "phantom" duration the player waited to arrive.
186+
firstPTS, lastEndPTS := pesBatchRange(videoBatch, audioBatch)
187+
startPTS := firstPTS
188+
segDur := h.config.SegmentDuration
189+
if lastEndPTS > firstPTS {
190+
segDur = time.Duration(lastEndPTS-firstPTS) * time.Second / 90000
186191
}
187-
h.ring.Push(tsData, h.config.SegmentDuration, startPTS, false)
192+
h.ring.Push(tsData, segDur, startPTS, false)
188193
logger.L.Debugw("HLS: framed segment",
189194
"mount", h.mount,
190195
"bytes", len(tsData),
196+
"duration", segDur,
191197
"audio_frames", len(audioBatch),
192198
"video_frames", len(videoBatch),
193199
"sequence", h.ring.Sequence()-1,
@@ -238,6 +244,34 @@ func (h *HLSOutput) segmentLoopFramed(ctx context.Context, audio, video *Track)
238244
}
239245
}
240246

247+
// pesBatchRange returns the earliest first-PTS across audio + video and the
248+
// latest end-PTS (last frame's PTS rounded up to include its own duration
249+
// by using the following frame's start, or falling back to the last PTS
250+
// itself when there's only one frame).
251+
func pesBatchRange(videoBatch, audioBatch []pesUnit) (first, end int64) {
252+
first = -1
253+
end = -1
254+
consider := func(batch []pesUnit) {
255+
for _, u := range batch {
256+
if first < 0 || u.pts < first {
257+
first = u.pts
258+
}
259+
if u.pts > end {
260+
end = u.pts
261+
}
262+
}
263+
}
264+
consider(videoBatch)
265+
consider(audioBatch)
266+
if first < 0 {
267+
first = 0
268+
}
269+
if end < first {
270+
end = first
271+
}
272+
return first, end
273+
}
274+
241275
// buildAVSegment muxes a list of audio + video access units into a single
242276
// MPEG-TS segment. Each frame becomes its own PES packet with its own
243277
// PTS.

relay/stream.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ type Stream struct {
8484
// bytes, frame-level consumers subscribe to the hub. nil on streams
8585
// that never see a frame-level producer (e.g. relay pull).
8686
Frames *FrameHub
87+
88+
// MinListenerOffset is a hard lower bound for listener reads of the
89+
// byte Buffer. Used when the source changes its codec config mid-
90+
// stream (e.g. OBS reconfigures and a new AVCDecoderConfigurationRecord
91+
// arrives) — any bytes written before that point were encoded with
92+
// parameters the new SPS/PPS no longer describes, so the decoder can
93+
// only crash on them. Subscribe() bumps a new listener's start
94+
// offset up to this value.
95+
MinListenerOffset int64
8796
LastPageOffset int64 // Absolute offset of the last valid Ogg page start
8897
PageOffsets []int64 // Circular list of last ~100 page starts
8998
PageIndex int // Index for managing PageOffsets circular list
@@ -138,6 +147,29 @@ func (s *Stream) StoreVideoHeaders(headers []byte) {
138147
s.VideoHeaders = headers
139148
}
140149

150+
// GetMinListenerOffset returns MinListenerOffset under the stream mutex.
151+
func (s *Stream) GetMinListenerOffset() int64 {
152+
s.mu.RLock()
153+
defer s.mu.RUnlock()
154+
return s.MinListenerOffset
155+
}
156+
157+
// CheckpointAtHead marks the current Buffer.Head as the new minimum
158+
// valid offset for listener reads, and clears any previously recorded
159+
// keyframe offsets (they pointed at bytes encoded under the old config).
160+
// Called by ingest paths when they observe a codec-parameter change
161+
// mid-stream — OBS restarting its encoder, resolution switch, etc.
162+
func (s *Stream) CheckpointAtHead() {
163+
if s.Buffer == nil {
164+
return
165+
}
166+
head := s.Buffer.HeadOffset()
167+
s.mu.Lock()
168+
s.MinListenerOffset = head
169+
s.mu.Unlock()
170+
s.Buffer.ResetKeyframes()
171+
}
172+
141173
// VideoInfo returns the flags that the HTTP listener path needs to
142174
// decide whether a stream is video and what headers to prepend, under
143175
// the stream mutex. Callers outside the relay package can't read the

server/handlers_stream.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,15 @@ func (s *Server) serveStreamData(w http.ResponseWriter, r *http.Request, stream
397397
logger.L.Debugf("Ogg Listener %s: Sending stored headers (%d bytes), then starting burst at %d", id, len(stream.OggHead), offset)
398398
}
399399

400+
// Respect the stream's minimum valid offset — set whenever the
401+
// source reconfigures its codec mid-stream. Without this a listener
402+
// that subscribes right after an OBS reconfig replays pre-reconfig
403+
// bytes (old SPS/PPS) and the decoder dies as soon as the new
404+
// parameters activate.
405+
if min := stream.GetMinListenerOffset(); min > offset {
406+
offset = min
407+
}
408+
400409
// For raw H.264 video listeners, seek back to the most recent
401410
// keyframe (so playback doesn't start on a P-frame whose reference
402411
// frames aren't in the listener's buffer) and prepend the cached

0 commit comments

Comments
 (0)