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    /// Whether the offer requested `a=sendonly`/`sendrecv` (a publisher).
16    pub send: bool,
17}
18
19impl SdpOffer {
20    /// Parse the offer, returning `None` if it has no video `m=` line.
21    pub fn parse(sdp: &str) -> Option<SdpOffer> {
22        let mut in_video = false;
23        let mut payload_type = None;
24        let mut mid = "0".to_string();
25        let mut send = false;
26        let mut h264_pt = None;
27
28        for line in sdp.lines() {
29            let line = line.trim_end();
30            if let Some(rest) = line.strip_prefix("m=") {
31                in_video = rest.starts_with("video");
32                if in_video {
33                    // First payload type on the m= line is the default.
34                    payload_type = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
35                }
36            } else if in_video {
37                if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
38                    if rtpmap.contains("H264") {
39                        h264_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
40                    }
41                } else if let Some(m) = line.strip_prefix("a=mid:") {
42                    mid = m.to_string();
43                } else if line == "a=sendonly" || line == "a=sendrecv" {
44                    send = true;
45                }
46            }
47        }
48
49        let payload_type = h264_pt.or(payload_type)?;
50        Some(SdpOffer {
51            mid,
52            payload_type,
53            send,
54        })
55    }
56}
57
58/// The crypto/ICE parameters the host injects into the answer.
59#[derive(Debug, Clone)]
60pub struct SdpAnswerParams {
61    /// `a=fingerprint` value (`sha-256 AA:BB:…`).
62    pub fingerprint: String,
63    /// `a=ice-ufrag` value.
64    pub ice_ufrag: String,
65    /// `a=ice-pwd` value.
66    pub ice_pwd: String,
67}
68
69/// The direction the server's media line takes in the answer: `recvonly` for
70/// WHIP ingest (the server receives), `sendonly` for WHEP egress (it sends).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum MediaDirection {
73    /// Server receives media (WHIP).
74    RecvOnly,
75    /// Server sends media (WHEP).
76    SendOnly,
77}
78
79impl MediaDirection {
80    fn attr(self) -> &'static str {
81        match self {
82            MediaDirection::RecvOnly => "recvonly",
83            MediaDirection::SendOnly => "sendonly",
84        }
85    }
86}
87
88/// Build a WHIP (recvonly) SDP answer for `offer`. See [`build_answer_directed`].
89pub fn build_answer(offer: &SdpOffer, params: &SdpAnswerParams) -> String {
90    build_answer_directed(offer, params, MediaDirection::RecvOnly)
91}
92
93/// Build an SDP answer for `offer` with an explicit media `direction`.
94///
95/// The answer is a minimal single-video description echoing the offer's payload
96/// type and mid, advertising the server's DTLS fingerprint and ICE credentials.
97/// A real connection's ICE candidates are trickled separately by the transport.
98pub fn build_answer_directed(
99    offer: &SdpOffer,
100    params: &SdpAnswerParams,
101    direction: MediaDirection,
102) -> String {
103    let pt = offer.payload_type;
104    format!(
105        "v=0\r\n\
106o=- 0 0 IN IP4 0.0.0.0\r\n\
107s=-\r\n\
108t=0 0\r\n\
109a=group:BUNDLE {mid}\r\n\
110m=video 9 UDP/TLS/RTP/SAVPF {pt}\r\n\
111c=IN IP4 0.0.0.0\r\n\
112a=rtcp-mux\r\n\
113a=mid:{mid}\r\n\
114a={dir}\r\n\
115a=ice-ufrag:{ufrag}\r\n\
116a=ice-pwd:{pwd}\r\n\
117a=fingerprint:{fp}\r\n\
118a=setup:passive\r\n\
119a=rtpmap:{pt} H264/90000\r\n\
120a=rtcp-fb:{pt} nack pli\r\n\
121a=rtcp-fb:{pt} ccm fir\r\n",
122        mid = offer.mid,
123        pt = pt,
124        dir = direction.attr(),
125        ufrag = params.ice_ufrag,
126        pwd = params.ice_pwd,
127        fp = params.fingerprint,
128    )
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    const OFFER: &str = "v=0\r\n\
136o=- 0 0 IN IP4 0.0.0.0\r\n\
137m=video 9 UDP/TLS/RTP/SAVPF 96 97\r\n\
138a=mid:vid\r\n\
139a=sendonly\r\n\
140a=rtpmap:96 H264/90000\r\n";
141
142    #[test]
143    fn parses_video_offer() {
144        let o = SdpOffer::parse(OFFER).unwrap();
145        assert_eq!(o.payload_type, 96);
146        assert_eq!(o.mid, "vid");
147        assert!(o.send);
148    }
149
150    #[test]
151    fn audio_only_offer_is_rejected() {
152        assert!(SdpOffer::parse("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n").is_none());
153    }
154
155    #[test]
156    fn answer_echoes_mid_and_payload_and_injects_crypto() {
157        let o = SdpOffer::parse(OFFER).unwrap();
158        let a = build_answer(
159            &o,
160            &SdpAnswerParams {
161                fingerprint: "sha-256 11:22".into(),
162                ice_ufrag: "uf".into(),
163                ice_pwd: "pw".into(),
164            },
165        );
166        assert!(a.contains("a=mid:vid"));
167        assert!(a.contains("m=video 9 UDP/TLS/RTP/SAVPF 96"));
168        assert!(a.contains("a=recvonly"));
169        assert!(a.contains("a=ice-ufrag:uf"));
170        assert!(a.contains("a=fingerprint:sha-256 11:22"));
171        assert!(a.contains("a=rtcp-fb:96 nack pli"));
172    }
173}