Skip to content

Commit 594d819

Browse files
authored
Merge pull request #42 from GyeongHoKim/fix/rpi
fix(rtsp): populate H.264 SDP parameter sets from first IDR
2 parents ee9fe64 + b8bb2f4 commit 594d819

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)