Skip to content

Commit b8bb2f4

Browse files
committed
fix(rtsp): populate H.264 SDP parameter sets from first IDR
LiveSource passed the rpicam probe through with empty SPS/PPS, so the gortsplib H264 format had no parameter sets and the DESCRIBE response omitted sprop-parameter-sets. Clients that subscribed between IDRs (or that needed SDP-level decoder init) saw slices arrive but never the SPS/PPS they reference and looped on "non-existing PPS 0 referenced / decode_slice_header error / no frame!" indefinitely. Extract SPS (NAL type 7) and PPS (NAL type 8) from the first IDR access unit and write them into the attached format.H264 before closing the ready channel. Because Ready gates DESCRIBE, the SDP that goes out the wire is generated after the mutation and now carries the parameter sets. A probe-supplied value (file-backed source) is preserved.
1 parent ee9fe64 commit b8bb2f4

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

internal/rtsp/livesource.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ type AccessUnit struct {
3030
// stuck clients do not balloon memory.
3131
const liveBufferSize = 8
3232

33+
// H.264 NAL unit types used by parameter-set extraction (ITU-T H.264 §7.3.1).
34+
const (
35+
h264NALTypeSPS = 7
36+
h264NALTypePPS = 8
37+
)
38+
3339
// LiveSource is a Source backed by an external H.264 producer. The producer
3440
// (e.g. internal/rpicamera) pushes AccessUnit values onto Push; the source's
3541
// Run goroutine encodes them into RTP packets and writes them to the
@@ -55,8 +61,11 @@ type LiveSource struct {
5561
}
5662

5763
// NewLiveSource builds a LiveSource for the codec described by probe. SPS
58-
// and PPS may be empty on the probe — clients learn them from in-band NAL
59-
// units once the stream starts. logger may be nil.
64+
// and PPS may be empty on the probe — they are extracted from the first
65+
// IDR access unit and written into the gortsplib H264 format before the
66+
// ready signal releases DESCRIBE, so the SDP carries sprop-parameter-sets
67+
// and clients are not stuck on "non-existing PPS referenced". logger may
68+
// be nil.
6069
func NewLiveSource(probe *ProbeResult, logger *slog.Logger) *LiveSource {
6170
if logger == nil {
6271
logger = obs.Discard()
@@ -140,6 +149,7 @@ func (l *LiveSource) Run(ctx context.Context) error {
140149
return nil
141150
}
142151
if hasH264IDR(au.NALs) {
152+
l.fillH264ParameterSets(au.NALs)
143153
l.readyOnce.Do(func() { close(l.ready) })
144154
}
145155
select {
@@ -176,6 +186,44 @@ func (l *LiveSource) writeAU(enc *rtph264.Encoder, au AccessUnit) error {
176186
return nil
177187
}
178188

189+
// fillH264ParameterSets pulls SPS (NAL type 7) and PPS (NAL type 8) out of
190+
// the first IDR access unit and writes them into the attached gortsplib
191+
// format.H264 so DESCRIBE emits sprop-parameter-sets in the SDP.
192+
//
193+
// Without this, clients that subscribe between IDRs never see SPS/PPS in
194+
// either the SDP (empty) or in-band (already passed) and decode loops
195+
// indefinitely on "non-existing PPS referenced".
196+
func (l *LiveSource) fillH264ParameterSets(nals [][]byte) {
197+
if l.media == nil || len(l.media.Formats) == 0 {
198+
return
199+
}
200+
h, ok := l.media.Formats[0].(*format.H264)
201+
if !ok {
202+
return
203+
}
204+
if len(h.SPS) > 0 && len(h.PPS) > 0 {
205+
return
206+
}
207+
var sps, pps []byte
208+
for _, nal := range nals {
209+
if len(nal) == 0 {
210+
continue
211+
}
212+
switch nal[0] & 0x1F {
213+
case h264NALTypeSPS:
214+
sps = nal
215+
case h264NALTypePPS:
216+
pps = nal
217+
}
218+
}
219+
if len(h.SPS) == 0 && len(sps) > 0 {
220+
h.SPS = sps
221+
}
222+
if len(h.PPS) == 0 && len(pps) > 0 {
223+
h.PPS = pps
224+
}
225+
}
226+
179227
// hasH264IDR scans access-unit NAL units for an IDR slice (NAL type 5).
180228
func hasH264IDR(au [][]byte) bool {
181229
for _, nal := range au {

internal/rtsp/livesource_test.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package rtsp
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"sync"
78
"testing"
89
"time"
10+
11+
"github.com/bluenviron/gortsplib/v5/pkg/description"
12+
"github.com/bluenviron/gortsplib/v5/pkg/format"
913
)
1014

1115
func TestHasH264IDR(t *testing.T) {
@@ -31,6 +35,75 @@ func TestHasH264IDR(t *testing.T) {
3135
}
3236
}
3337

38+
func TestLiveSourceFillsH264ParameterSetsFromFirstIDR(t *testing.T) {
39+
t.Parallel()
40+
sps := []byte{0x67, 0x42, 0xc0, 0x1e}
41+
pps := []byte{0x68, 0xce, 0x3c, 0x80}
42+
idr := []byte{0x65, 0xb8, 0x00, 0x01}
43+
44+
cases := []struct {
45+
name string
46+
probe *ProbeResult
47+
nals [][]byte
48+
wantSPS []byte
49+
wantPPS []byte
50+
}{
51+
{
52+
name: "writes SPS+PPS when probe is empty",
53+
probe: &ProbeResult{Codec: CodecH264},
54+
nals: [][]byte{sps, pps, idr},
55+
wantSPS: sps,
56+
wantPPS: pps,
57+
},
58+
{
59+
name: "missing PPS leaves PPS untouched",
60+
probe: &ProbeResult{Codec: CodecH264},
61+
nals: [][]byte{sps, idr},
62+
wantSPS: sps,
63+
wantPPS: nil,
64+
},
65+
{
66+
name: "preserves probe-supplied SPS+PPS",
67+
probe: &ProbeResult{Codec: CodecH264, SPS: []byte{0xAA}, PPS: []byte{0xBB}},
68+
nals: [][]byte{sps, pps, idr},
69+
wantSPS: []byte{0xAA},
70+
wantPPS: []byte{0xBB},
71+
},
72+
}
73+
for _, tc := range cases {
74+
t.Run(tc.name, func(t *testing.T) {
75+
t.Parallel()
76+
ls := NewLiveSource(tc.probe, nil)
77+
h := &format.H264{
78+
PayloadTyp: 96,
79+
PacketizationMode: 1,
80+
SPS: tc.probe.SPS,
81+
PPS: tc.probe.PPS,
82+
}
83+
media := &description.Media{
84+
Type: description.MediaTypeVideo,
85+
Formats: []format.Format{h},
86+
}
87+
ls.AttachStream(nil, media)
88+
89+
ls.fillH264ParameterSets(tc.nals)
90+
91+
if !bytes.Equal(h.SPS, tc.wantSPS) {
92+
t.Fatalf("SPS=%x want %x", h.SPS, tc.wantSPS)
93+
}
94+
if !bytes.Equal(h.PPS, tc.wantPPS) {
95+
t.Fatalf("PPS=%x want %x", h.PPS, tc.wantPPS)
96+
}
97+
})
98+
}
99+
}
100+
101+
func TestLiveSourceFillH264ParameterSetsNoMediaIsNoOp(t *testing.T) {
102+
t.Parallel()
103+
ls := NewLiveSource(&ProbeResult{Codec: CodecH264}, nil)
104+
ls.fillH264ParameterSets([][]byte{{0x67, 0x42}, {0x68, 0xce}})
105+
}
106+
34107
func TestLiveSourceReadyBeforeIDRBlocks(t *testing.T) {
35108
t.Parallel()
36109
probe := &ProbeResult{Codec: CodecH264, Width: 1920, Height: 1080, FPS: 30}

0 commit comments

Comments
 (0)