Skip to main content

arcly_stream/protocol/webrtc/
sdp.rs

1//! WebRTC SDP offer parsing and answer generation (RFC 8866 + JSEP).
2//!
3//! Just enough of the offer is parsed to emit a compatible single-video answer:
4//! the media line, the chosen H.264 payload type, and the bundle/mid grouping.
5//! The crypto-bearing lines in the answer (`a=fingerprint`, `a=ice-ufrag`,
6//! `a=ice-pwd`) come from the host's [`DtlsSrtpTransport`](super::DtlsSrtpTransport).
7
8/// A parsed WHIP/WHEP SDP offer (only the fields the answer needs).
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct SdpOffer {
11    /// The video media `mid` (defaults to `"0"` when absent).
12    pub mid: String,
13    /// The negotiated H.264 dynamic payload type (e.g. 96).
14    pub payload_type: u8,
15    /// The negotiated Opus audio payload type, when the offer carries an audio
16    /// `m=` line (e.g. 111). `None` for a video-only offer.
17    pub audio_payload_type: Option<u8>,
18    /// Whether the offer requested `a=sendonly`/`sendrecv` (a publisher).
19    pub send: bool,
20    /// The RTP header-extension id negotiated for the RID
21    /// (`urn:ietf:params:rtp-hdrext:sdes:rid`), if the offer carries one. Needed
22    /// to read [`simulcast_rids`](Self::simulcast_rids) layers off the RTP stream.
23    pub rid_ext_id: Option<u8>,
24    /// The simulcast send layers the publisher offers, in declared order (the
25    /// rid identifiers from `a=simulcast:send` / `a=rid:… send`). Empty when the
26    /// offer is not simulcast.
27    pub simulcast_rids: Vec<String>,
28    /// The `m=application` data-channel `mid`, when the offer negotiates an SCTP
29    /// (`webrtc-datachannel`) data channel. `None` if there is no data channel.
30    pub data_mid: Option<String>,
31}
32
33impl SdpOffer {
34    /// Whether the offer negotiates an SCTP data channel.
35    pub fn has_data_channel(&self) -> bool {
36        self.data_mid.is_some()
37    }
38
39    /// Whether the offer carries more than one simulcast layer.
40    pub fn is_simulcast(&self) -> bool {
41        self.simulcast_rids.len() > 1
42    }
43
44    /// Parse the offer, returning `None` if it has no video `m=` line.
45    ///
46    /// Audio is parsed opportunistically: a publisher (WHIP) offering both video
47    /// and Opus audio yields [`audio_payload_type`](Self::audio_payload_type) so
48    /// the ingest pump can route audio RTP onto the bus. Video remains required
49    /// (audio-only offers return `None`). Simulcast (`a=simulcast`/`a=rid`) and a
50    /// data-channel `m=application` line are captured when present.
51    pub fn parse(sdp: &str) -> Option<SdpOffer> {
52        #[derive(PartialEq)]
53        enum Section {
54            None,
55            Video,
56            Audio,
57            Application,
58        }
59        let mut section = Section::None;
60        let mut payload_type = None;
61        let mut mid = "0".to_string();
62        let mut send = false;
63        let mut h264_pt = None;
64        let mut opus_pt = None;
65        let mut audio_default_pt = None;
66        let mut rid_ext_id = None;
67        let mut rid_send: Vec<String> = Vec::new();
68        let mut simulcast: Vec<String> = Vec::new();
69        let mut data_mid = None;
70        let mut app_mid = "data".to_string();
71
72        for line in sdp.lines() {
73            let line = line.trim_end();
74            if let Some(rest) = line.strip_prefix("m=") {
75                if rest.starts_with("video") {
76                    section = Section::Video;
77                    // First payload type on the m= line is the default.
78                    payload_type = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
79                } else if rest.starts_with("audio") {
80                    section = Section::Audio;
81                    audio_default_pt = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
82                } else if rest.starts_with("application") {
83                    section = Section::Application;
84                    data_mid = Some(app_mid.clone());
85                } else {
86                    section = Section::None;
87                }
88            } else if section == Section::Video {
89                if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
90                    // Keep the *first* H.264 payload type. Browsers list several
91                    // (different profiles/packetization modes); the first is the
92                    // baseline `42001f`, packetization-mode=1 profile that a
93                    // sender (and str0m's answer) reliably keeps. Picking the last
94                    // can land on a high profile the answerer drops, leaving the
95                    // egress PT un-negotiated and the RTP silently undeliverable.
96                    if rtpmap.contains("H264") && h264_pt.is_none() {
97                        h264_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
98                    }
99                } else if let Some(m) = line.strip_prefix("a=mid:") {
100                    mid = m.to_string();
101                } else if let Some(ext) = line.strip_prefix("a=extmap:") {
102                    // `a=extmap:<id>[/dir] <uri>` — capture the RID extension id.
103                    if ext.contains("sdes:rid") && !ext.contains("repaired") {
104                        rid_ext_id = ext.split(['/', ' ']).next().and_then(|id| id.parse().ok());
105                    }
106                } else if let Some(rid) = line.strip_prefix("a=rid:") {
107                    // `a=rid:<id> send` — a simulcast layer the publisher sends.
108                    let mut it = rid.split_whitespace();
109                    if let (Some(id), Some(dir)) = (it.next(), it.next()) {
110                        if dir == "send" {
111                            rid_send.push(id.to_string());
112                        }
113                    }
114                } else if let Some(sc) = line.strip_prefix("a=simulcast:") {
115                    // `a=simulcast:send <alt>;<alt>;…` — take the primary rid of
116                    // each `;`-separated layer (ignoring `,`-separated alternatives).
117                    if let Some(send_list) = sc.strip_prefix("send ") {
118                        simulcast = send_list
119                            .split(';')
120                            .filter_map(|alt| alt.split(',').next())
121                            .map(|r| r.trim_start_matches('~').to_string())
122                            .filter(|r| !r.is_empty())
123                            .collect();
124                    }
125                } else if line == "a=sendonly" || line == "a=sendrecv" {
126                    send = true;
127                }
128            } else if section == Section::Audio {
129                if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
130                    if rtpmap.to_ascii_lowercase().contains("opus") && opus_pt.is_none() {
131                        opus_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
132                    }
133                } else if line == "a=sendonly" || line == "a=sendrecv" {
134                    send = true;
135                }
136            } else if section == Section::Application {
137                if let Some(m) = line.strip_prefix("a=mid:") {
138                    app_mid = m.to_string();
139                    data_mid = Some(app_mid.clone());
140                }
141            }
142        }
143
144        let payload_type = h264_pt.or(payload_type)?;
145        // Prefer the explicit `a=simulcast` order; fall back to the rid lines.
146        let simulcast_rids = if !simulcast.is_empty() {
147            simulcast
148        } else {
149            rid_send
150        };
151        Some(SdpOffer {
152            mid,
153            payload_type,
154            audio_payload_type: opus_pt.or(audio_default_pt),
155            send,
156            rid_ext_id,
157            simulcast_rids,
158            data_mid,
159        })
160    }
161}
162
163/// The crypto/ICE parameters the host injects into the answer.
164#[derive(Debug, Clone)]
165pub struct SdpAnswerParams {
166    /// `a=fingerprint` value (`sha-256 AA:BB:…`).
167    pub fingerprint: String,
168    /// `a=ice-ufrag` value.
169    pub ice_ufrag: String,
170    /// `a=ice-pwd` value.
171    pub ice_pwd: String,
172}
173
174/// The direction the server's media line takes in the answer: `recvonly` for
175/// WHIP ingest (the server receives), `sendonly` for WHEP egress (it sends).
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum MediaDirection {
178    /// Server receives media (WHIP).
179    RecvOnly,
180    /// Server sends media (WHEP).
181    SendOnly,
182}
183
184impl MediaDirection {
185    fn attr(self) -> &'static str {
186        match self {
187            MediaDirection::RecvOnly => "recvonly",
188            MediaDirection::SendOnly => "sendonly",
189        }
190    }
191}
192
193/// Build a WHIP (recvonly) SDP answer for `offer`. See [`build_answer_directed`].
194pub fn build_answer(offer: &SdpOffer, params: &SdpAnswerParams) -> String {
195    build_answer_directed(offer, params, MediaDirection::RecvOnly)
196}
197
198/// Build an SDP answer for `offer` with an explicit media `direction`.
199///
200/// The answer is a minimal single-video description echoing the offer's payload
201/// type and mid, advertising the server's DTLS fingerprint and ICE credentials.
202/// A real connection's ICE candidates are trickled separately by the transport.
203pub fn build_answer_directed(
204    offer: &SdpOffer,
205    params: &SdpAnswerParams,
206    direction: MediaDirection,
207) -> String {
208    let pt = offer.payload_type;
209    // BUNDLE every accepted m-line (video, and the data channel when offered).
210    let bundle = match &offer.data_mid {
211        Some(d) => format!("{} {}", offer.mid, d),
212        None => offer.mid.clone(),
213    };
214    let mut sdp = format!(
215        "v=0\r\n\
216o=- 0 0 IN IP4 0.0.0.0\r\n\
217s=-\r\n\
218t=0 0\r\n\
219a=group:BUNDLE {bundle}\r\n\
220m=video 9 UDP/TLS/RTP/SAVPF {pt}\r\n\
221c=IN IP4 0.0.0.0\r\n\
222a=rtcp-mux\r\n\
223a=mid:{mid}\r\n\
224a={dir}\r\n\
225a=ice-ufrag:{ufrag}\r\n\
226a=ice-pwd:{pwd}\r\n\
227a=fingerprint:{fp}\r\n\
228a=setup:passive\r\n\
229a=rtpmap:{pt} H264/90000\r\n\
230a=rtcp-fb:{pt} nack pli\r\n\
231a=rtcp-fb:{pt} ccm fir\r\n",
232        bundle = bundle,
233        mid = offer.mid,
234        pt = pt,
235        dir = direction.attr(),
236        ufrag = params.ice_ufrag,
237        pwd = params.ice_pwd,
238        fp = params.fingerprint,
239    );
240
241    // Simulcast: accept the publisher's layers as `recv` (the server is the SFU
242    // receiving every layer). Echo the RID extension and each rid.
243    if direction == MediaDirection::RecvOnly && offer.is_simulcast() {
244        if let Some(id) = offer.rid_ext_id {
245            sdp.push_str(&format!(
246                "a=extmap:{id} urn:ietf:params:rtp-hdrext:sdes:rid\r\n"
247            ));
248        }
249        for rid in &offer.simulcast_rids {
250            sdp.push_str(&format!("a=rid:{rid} recv\r\n"));
251        }
252        sdp.push_str(&format!(
253            "a=simulcast:recv {}\r\n",
254            offer.simulcast_rids.join(";")
255        ));
256    }
257
258    // Data channel: accept the SCTP association on its own m-line.
259    if let Some(data_mid) = &offer.data_mid {
260        sdp.push_str(&format!(
261            "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n\
262c=IN IP4 0.0.0.0\r\n\
263a=mid:{data_mid}\r\n\
264a=ice-ufrag:{ufrag}\r\n\
265a=ice-pwd:{pwd}\r\n\
266a=fingerprint:{fp}\r\n\
267a=setup:passive\r\n\
268a=sctp-port:5000\r\n",
269            data_mid = data_mid,
270            ufrag = params.ice_ufrag,
271            pwd = params.ice_pwd,
272            fp = params.fingerprint,
273        ));
274    }
275
276    sdp
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    const OFFER: &str = "v=0\r\n\
284o=- 0 0 IN IP4 0.0.0.0\r\n\
285m=video 9 UDP/TLS/RTP/SAVPF 96 97\r\n\
286a=mid:vid\r\n\
287a=sendonly\r\n\
288a=rtpmap:96 H264/90000\r\n";
289
290    #[test]
291    fn parses_video_offer() {
292        let o = SdpOffer::parse(OFFER).unwrap();
293        assert_eq!(o.payload_type, 96);
294        assert_eq!(o.mid, "vid");
295        assert!(o.send);
296    }
297
298    #[test]
299    fn audio_only_offer_is_rejected() {
300        assert!(SdpOffer::parse("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n").is_none());
301    }
302
303    #[test]
304    fn parses_opus_audio_payload_alongside_video() {
305        let offer = "v=0\r\n\
306m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
307a=mid:vid\r\n\
308a=sendonly\r\n\
309a=rtpmap:96 H264/90000\r\n\
310m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\
311a=mid:aud\r\n\
312a=sendonly\r\n\
313a=rtpmap:111 opus/48000/2\r\n";
314        let o = SdpOffer::parse(offer).unwrap();
315        assert_eq!(o.payload_type, 96);
316        assert_eq!(o.audio_payload_type, Some(111));
317        // Video-only offer carries no audio PT.
318        assert_eq!(SdpOffer::parse(OFFER).unwrap().audio_payload_type, None);
319    }
320
321    #[test]
322    fn answer_echoes_mid_and_payload_and_injects_crypto() {
323        let o = SdpOffer::parse(OFFER).unwrap();
324        let a = build_answer(
325            &o,
326            &SdpAnswerParams {
327                fingerprint: "sha-256 11:22".into(),
328                ice_ufrag: "uf".into(),
329                ice_pwd: "pw".into(),
330            },
331        );
332        assert!(a.contains("a=mid:vid"));
333        assert!(a.contains("m=video 9 UDP/TLS/RTP/SAVPF 96"));
334        assert!(a.contains("a=recvonly"));
335        assert!(a.contains("a=ice-ufrag:uf"));
336        assert!(a.contains("a=fingerprint:sha-256 11:22"));
337        assert!(a.contains("a=rtcp-fb:96 nack pli"));
338    }
339
340    const SIMULCAST_OFFER: &str = "v=0\r\n\
341o=- 0 0 IN IP4 0.0.0.0\r\n\
342m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
343a=mid:0\r\n\
344a=sendonly\r\n\
345a=rtpmap:96 H264/90000\r\n\
346a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid\r\n\
347a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n\
348a=rid:q send\r\n\
349a=rid:h send\r\n\
350a=rid:f send\r\n\
351a=simulcast:send q;h;f\r\n";
352
353    #[test]
354    fn parses_simulcast_layers_and_rid_extension() {
355        let o = SdpOffer::parse(SIMULCAST_OFFER).unwrap();
356        assert!(o.is_simulcast());
357        assert_eq!(o.rid_ext_id, Some(4)); // not the "repaired" extmap id 5
358        assert_eq!(o.simulcast_rids, vec!["q", "h", "f"]);
359    }
360
361    #[test]
362    fn answer_echoes_simulcast_as_recv() {
363        let o = SdpOffer::parse(SIMULCAST_OFFER).unwrap();
364        let a = build_answer(
365            &o,
366            &SdpAnswerParams {
367                fingerprint: "sha-256 11:22".into(),
368                ice_ufrag: "uf".into(),
369                ice_pwd: "pw".into(),
370            },
371        );
372        assert!(a.contains("a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid"));
373        assert!(a.contains("a=rid:q recv"));
374        assert!(a.contains("a=rid:f recv"));
375        assert!(a.contains("a=simulcast:recv q;h;f"));
376    }
377
378    #[test]
379    fn parses_and_answers_data_channel() {
380        let offer = "v=0\r\n\
381o=- 0 0 IN IP4 0.0.0.0\r\n\
382m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
383a=mid:0\r\n\
384a=sendonly\r\n\
385a=rtpmap:96 H264/90000\r\n\
386m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n\
387a=mid:dc\r\n\
388a=sctp-port:5000\r\n";
389        let o = SdpOffer::parse(offer).unwrap();
390        assert!(o.has_data_channel());
391        assert_eq!(o.data_mid.as_deref(), Some("dc"));
392
393        let a = build_answer(
394            &o,
395            &SdpAnswerParams {
396                fingerprint: "sha-256 11:22".into(),
397                ice_ufrag: "uf".into(),
398                ice_pwd: "pw".into(),
399            },
400        );
401        assert!(a.contains("a=group:BUNDLE 0 dc"));
402        assert!(a.contains("m=application 9 UDP/DTLS/SCTP webrtc-datachannel"));
403        assert!(a.contains("a=mid:dc"));
404        assert!(a.contains("a=sctp-port:5000"));
405    }
406}