arcly_stream/protocol/rtsp/
sdp.rs1#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct MediaDescription {
10 pub media: String,
12 pub payload_type: u8,
14 pub encoding: Option<String>,
16 pub clock_rate: Option<u32>,
18 pub aac_size_length: Option<u8>,
20 pub aac_index_length: Option<u8>,
22 pub control: Option<String>,
24 pub sprop_sets: Vec<String>,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct Sdp {
33 pub media: Vec<MediaDescription>,
35}
36
37impl Sdp {
38 pub fn parse(text: &str) -> Sdp {
41 let mut media: Vec<MediaDescription> = Vec::new();
42 for line in text.lines() {
43 let line = line.trim_end();
44 if let Some(rest) = line.strip_prefix("m=") {
45 let mut parts = rest.split(' ');
47 let kind = parts.next().unwrap_or("").to_string();
48 let pt = parts.nth(2).and_then(|p| p.parse().ok()).unwrap_or(0);
49 media.push(MediaDescription {
50 media: kind,
51 payload_type: pt,
52 encoding: None,
53 clock_rate: None,
54 aac_size_length: None,
55 aac_index_length: None,
56 control: None,
57 sprop_sets: Vec::new(),
58 });
59 } else if let Some(rest) = line.strip_prefix("a=") {
60 let Some(current) = media.last_mut() else {
61 continue;
62 };
63 if let Some(rtpmap) = rest.strip_prefix("rtpmap:") {
64 if let Some((_, enc)) = rtpmap.split_once(' ') {
66 let mut fields = enc.split('/');
67 current.encoding = fields.next().map(|s| s.to_string());
68 current.clock_rate = fields.next().and_then(|s| s.parse().ok());
69 }
70 } else if let Some(fmtp) = rest.strip_prefix("fmtp:") {
71 for param in fmtp.split([';', ' ']) {
73 let param = param.trim();
74 if let Some(v) = param.strip_prefix("sizelength=") {
75 current.aac_size_length = v.parse().ok();
76 } else if let Some(v) = param.strip_prefix("indexlength=") {
77 current.aac_index_length = v.parse().ok();
78 } else if let Some(v) = param.strip_prefix("sprop-parameter-sets=") {
79 current
81 .sprop_sets
82 .extend(v.split(',').filter(|s| !s.is_empty()).map(String::from));
83 } else if let Some(v) = param
84 .strip_prefix("sprop-vps=")
85 .or_else(|| param.strip_prefix("sprop-sps="))
86 .or_else(|| param.strip_prefix("sprop-pps="))
87 {
88 if !v.is_empty() {
90 current.sprop_sets.push(v.to_string());
91 }
92 }
93 }
94 } else if let Some(control) = rest.strip_prefix("control:") {
95 current.control = Some(control.to_string());
96 }
97 }
98 }
99 Sdp { media }
100 }
101
102 pub fn first_control(&self, kind: &str, base_url: &str) -> Option<String> {
106 let media = self.media.iter().find(|m| m.media == kind)?;
107 let control = media.control.as_deref().unwrap_or("");
108 if control.is_empty() || control == "*" {
109 Some(base_url.to_string())
110 } else if control.starts_with("rtsp://") {
111 Some(control.to_string())
112 } else {
113 Some(format!("{}/{}", base_url.trim_end_matches('/'), control))
114 }
115 }
116
117 pub fn first_video_control(&self, base_url: &str) -> Option<String> {
119 self.first_control("video", base_url)
120 }
121
122 pub fn first_audio_control(&self, base_url: &str) -> Option<String> {
124 self.first_control("audio", base_url)
125 }
126
127 pub fn audio_aac_lengths(&self) -> (u8, u8) {
130 let audio = self.media.iter().find(|m| m.media == "audio");
131 let size = audio.and_then(|m| m.aac_size_length).unwrap_or(13);
132 let index = audio.and_then(|m| m.aac_index_length).unwrap_or(3);
133 (size, index)
134 }
135
136 pub fn video_config_annexb(&self) -> Option<bytes::Bytes> {
142 let video = self.media.iter().find(|m| m.media == "video")?;
143 let mut out = Vec::new();
144 for b64 in &video.sprop_sets {
145 let nal = super::base64::decode(b64)?;
146 if nal.is_empty() {
147 continue;
148 }
149 out.extend_from_slice(&[0, 0, 0, 1]);
150 out.extend_from_slice(&nal);
151 }
152 (!out.is_empty()).then(|| bytes::Bytes::from(out))
153 }
154
155 pub fn has_aac_audio(&self) -> bool {
157 self.media.iter().any(|m| {
158 m.media == "audio"
159 && m.encoding
160 .as_deref()
161 .is_some_and(|e| e.eq_ignore_ascii_case("MPEG4-GENERIC"))
162 })
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 const SAMPLE: &str = "v=0\r\n\
171o=- 0 0 IN IP4 127.0.0.1\r\n\
172s=Session\r\n\
173m=video 0 RTP/AVP 96\r\n\
174a=rtpmap:96 H264/90000\r\n\
175a=control:trackID=1\r\n\
176m=audio 0 RTP/AVP 97\r\n\
177a=rtpmap:97 MPEG4-GENERIC/48000/2\r\n\
178a=fmtp:97 streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3\r\n\
179a=control:trackID=2\r\n";
180
181 #[test]
182 fn parses_both_media_tracks() {
183 let sdp = Sdp::parse(SAMPLE);
184 assert_eq!(sdp.media.len(), 2);
185 let v = &sdp.media[0];
186 assert_eq!(v.media, "video");
187 assert_eq!(v.payload_type, 96);
188 assert_eq!(v.encoding.as_deref(), Some("H264"));
189 assert_eq!(v.control.as_deref(), Some("trackID=1"));
190 assert_eq!(sdp.media[1].encoding.as_deref(), Some("MPEG4-GENERIC"));
191 }
192
193 #[test]
194 fn parses_aac_fmtp_lengths_and_falls_back_to_defaults() {
195 let sdp = Sdp::parse(SAMPLE);
196 assert_eq!(sdp.media[1].aac_size_length, Some(13));
197 assert_eq!(sdp.media[1].aac_index_length, Some(3));
198 assert_eq!(sdp.audio_aac_lengths(), (13, 3));
199
200 let no_fmtp = Sdp::parse("m=audio 0 RTP/AVP 97\r\na=rtpmap:97 MPEG4-GENERIC/44100\r\n");
202 assert_eq!(no_fmtp.audio_aac_lengths(), (13, 3));
203
204 let custom = Sdp::parse(
206 "m=audio 0 RTP/AVP 97\r\na=fmtp:97 mode=AAC-hbr;sizelength=6;indexlength=2\r\n",
207 );
208 assert_eq!(custom.audio_aac_lengths(), (6, 2));
209 }
210
211 #[test]
212 fn builds_relative_control_url() {
213 let sdp = Sdp::parse(SAMPLE);
214 assert_eq!(
215 sdp.first_video_control("rtsp://cam/stream"),
216 Some("rtsp://cam/stream/trackID=1".to_string())
217 );
218 }
219
220 #[test]
221 fn absolute_control_url_overrides_base() {
222 let sdp = Sdp::parse("m=video 0 RTP/AVP 96\r\na=control:rtsp://cam/abs\r\n");
223 assert_eq!(
224 sdp.first_video_control("rtsp://cam/stream"),
225 Some("rtsp://cam/abs".to_string())
226 );
227 }
228
229 #[test]
230 fn empty_sdp_yields_no_media() {
231 assert!(Sdp::parse("v=0\r\n").media.is_empty());
232 }
233
234 #[test]
235 fn resolves_audio_control_and_detects_aac() {
236 let sdp = Sdp::parse(SAMPLE);
237 assert!(sdp.has_aac_audio());
238 assert_eq!(
239 sdp.first_audio_control("rtsp://cam/stream"),
240 Some("rtsp://cam/stream/trackID=2".to_string())
241 );
242 }
243}
244
245#[cfg(test)]
246mod ffmpeg_sprop_tests {
247 use super::*;
248 #[test]
249 fn extracts_ffmpeg_h264_sprop() {
250 let sdp = Sdp::parse("m=video 0 RTP/AVP 96\r\na=rtpmap:96 H264/90000\r\na=fmtp:96 packetization-mode=1; sprop-parameter-sets=Z0LADNoFB+wEQAAAAwBAAAAHg8UKqA==,aM4PyA==; profile-level-id=42C00C\r\n");
251 let cfg = sdp.video_config_annexb().expect("config present");
252 assert_eq!(&cfg[0..4], &[0, 0, 0, 1]);
254 assert_eq!(cfg[4] & 0x1f, 7, "first NAL is SPS");
255 assert!(
256 cfg.windows(4).filter(|w| *w == [0, 0, 0, 1]).count() >= 2,
257 "two start codes"
258 );
259 }
260}