arcly-stream 0.1.7

An open-extensible live-media streaming kernel: lock-free zero-copy frame fan-out, instant-start GOP cache, a pluggable multi-protocol ingestion layer (RTMP, RTSP, SRT, WHIP/WHEP shipped), and a feature-gated pure-Rust media plane (MPEG-TS/HLS/fMP4) — runtime, config, and metrics free.
Documentation
//! WebRTC SDP offer parsing and answer generation (RFC 8866 + JSEP).
//!
//! Just enough of the offer is parsed to emit a compatible single-video answer:
//! the media line, the chosen H.264 payload type, and the bundle/mid grouping.
//! The crypto-bearing lines in the answer (`a=fingerprint`, `a=ice-ufrag`,
//! `a=ice-pwd`) come from the host's [`DtlsSrtpTransport`](super::DtlsSrtpTransport).

/// A parsed WHIP/WHEP SDP offer (only the fields the answer needs).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SdpOffer {
    /// The video media `mid` (defaults to `"0"` when absent).
    pub mid: String,
    /// The negotiated H.264 dynamic payload type (e.g. 96).
    pub payload_type: u8,
    /// The negotiated Opus audio payload type, when the offer carries an audio
    /// `m=` line (e.g. 111). `None` for a video-only offer.
    pub audio_payload_type: Option<u8>,
    /// Whether the offer requested `a=sendonly`/`sendrecv` (a publisher).
    pub send: bool,
}

impl SdpOffer {
    /// Parse the offer, returning `None` if it has no video `m=` line.
    ///
    /// Audio is parsed opportunistically: a publisher (WHIP) offering both video
    /// and Opus audio yields [`audio_payload_type`](Self::audio_payload_type) so
    /// the ingest pump can route audio RTP onto the bus. Video remains required
    /// (audio-only offers return `None`).
    pub fn parse(sdp: &str) -> Option<SdpOffer> {
        #[derive(PartialEq)]
        enum Section {
            None,
            Video,
            Audio,
        }
        let mut section = Section::None;
        let mut payload_type = None;
        let mut mid = "0".to_string();
        let mut send = false;
        let mut h264_pt = None;
        let mut opus_pt = None;
        let mut audio_default_pt = None;

        for line in sdp.lines() {
            let line = line.trim_end();
            if let Some(rest) = line.strip_prefix("m=") {
                if rest.starts_with("video") {
                    section = Section::Video;
                    // First payload type on the m= line is the default.
                    payload_type = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
                } else if rest.starts_with("audio") {
                    section = Section::Audio;
                    audio_default_pt = rest.split(' ').nth(3).and_then(|p| p.parse().ok());
                } else {
                    section = Section::None;
                }
            } else if section == Section::Video {
                if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
                    // Keep the *first* H.264 payload type. Browsers list several
                    // (different profiles/packetization modes); the first is the
                    // baseline `42001f`, packetization-mode=1 profile that a
                    // sender (and str0m's answer) reliably keeps. Picking the last
                    // can land on a high profile the answerer drops, leaving the
                    // egress PT un-negotiated and the RTP silently undeliverable.
                    if rtpmap.contains("H264") && h264_pt.is_none() {
                        h264_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
                    }
                } else if let Some(m) = line.strip_prefix("a=mid:") {
                    mid = m.to_string();
                } else if line == "a=sendonly" || line == "a=sendrecv" {
                    send = true;
                }
            } else if section == Section::Audio {
                if let Some(rtpmap) = line.strip_prefix("a=rtpmap:") {
                    if rtpmap.to_ascii_lowercase().contains("opus") && opus_pt.is_none() {
                        opus_pt = rtpmap.split(' ').next().and_then(|p| p.parse().ok());
                    }
                } else if line == "a=sendonly" || line == "a=sendrecv" {
                    send = true;
                }
            }
        }

        let payload_type = h264_pt.or(payload_type)?;
        Some(SdpOffer {
            mid,
            payload_type,
            audio_payload_type: opus_pt.or(audio_default_pt),
            send,
        })
    }
}

/// The crypto/ICE parameters the host injects into the answer.
#[derive(Debug, Clone)]
pub struct SdpAnswerParams {
    /// `a=fingerprint` value (`sha-256 AA:BB:…`).
    pub fingerprint: String,
    /// `a=ice-ufrag` value.
    pub ice_ufrag: String,
    /// `a=ice-pwd` value.
    pub ice_pwd: String,
}

/// The direction the server's media line takes in the answer: `recvonly` for
/// WHIP ingest (the server receives), `sendonly` for WHEP egress (it sends).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MediaDirection {
    /// Server receives media (WHIP).
    RecvOnly,
    /// Server sends media (WHEP).
    SendOnly,
}

impl MediaDirection {
    fn attr(self) -> &'static str {
        match self {
            MediaDirection::RecvOnly => "recvonly",
            MediaDirection::SendOnly => "sendonly",
        }
    }
}

/// Build a WHIP (recvonly) SDP answer for `offer`. See [`build_answer_directed`].
pub fn build_answer(offer: &SdpOffer, params: &SdpAnswerParams) -> String {
    build_answer_directed(offer, params, MediaDirection::RecvOnly)
}

/// Build an SDP answer for `offer` with an explicit media `direction`.
///
/// The answer is a minimal single-video description echoing the offer's payload
/// type and mid, advertising the server's DTLS fingerprint and ICE credentials.
/// A real connection's ICE candidates are trickled separately by the transport.
pub fn build_answer_directed(
    offer: &SdpOffer,
    params: &SdpAnswerParams,
    direction: MediaDirection,
) -> String {
    let pt = offer.payload_type;
    format!(
        "v=0\r\n\
o=- 0 0 IN IP4 0.0.0.0\r\n\
s=-\r\n\
t=0 0\r\n\
a=group:BUNDLE {mid}\r\n\
m=video 9 UDP/TLS/RTP/SAVPF {pt}\r\n\
c=IN IP4 0.0.0.0\r\n\
a=rtcp-mux\r\n\
a=mid:{mid}\r\n\
a={dir}\r\n\
a=ice-ufrag:{ufrag}\r\n\
a=ice-pwd:{pwd}\r\n\
a=fingerprint:{fp}\r\n\
a=setup:passive\r\n\
a=rtpmap:{pt} H264/90000\r\n\
a=rtcp-fb:{pt} nack pli\r\n\
a=rtcp-fb:{pt} ccm fir\r\n",
        mid = offer.mid,
        pt = pt,
        dir = direction.attr(),
        ufrag = params.ice_ufrag,
        pwd = params.ice_pwd,
        fp = params.fingerprint,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    const OFFER: &str = "v=0\r\n\
o=- 0 0 IN IP4 0.0.0.0\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96 97\r\n\
a=mid:vid\r\n\
a=sendonly\r\n\
a=rtpmap:96 H264/90000\r\n";

    #[test]
    fn parses_video_offer() {
        let o = SdpOffer::parse(OFFER).unwrap();
        assert_eq!(o.payload_type, 96);
        assert_eq!(o.mid, "vid");
        assert!(o.send);
    }

    #[test]
    fn audio_only_offer_is_rejected() {
        assert!(SdpOffer::parse("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n").is_none());
    }

    #[test]
    fn parses_opus_audio_payload_alongside_video() {
        let offer = "v=0\r\n\
m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
a=mid:vid\r\n\
a=sendonly\r\n\
a=rtpmap:96 H264/90000\r\n\
m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\
a=mid:aud\r\n\
a=sendonly\r\n\
a=rtpmap:111 opus/48000/2\r\n";
        let o = SdpOffer::parse(offer).unwrap();
        assert_eq!(o.payload_type, 96);
        assert_eq!(o.audio_payload_type, Some(111));
        // Video-only offer carries no audio PT.
        assert_eq!(SdpOffer::parse(OFFER).unwrap().audio_payload_type, None);
    }

    #[test]
    fn answer_echoes_mid_and_payload_and_injects_crypto() {
        let o = SdpOffer::parse(OFFER).unwrap();
        let a = build_answer(
            &o,
            &SdpAnswerParams {
                fingerprint: "sha-256 11:22".into(),
                ice_ufrag: "uf".into(),
                ice_pwd: "pw".into(),
            },
        );
        assert!(a.contains("a=mid:vid"));
        assert!(a.contains("m=video 9 UDP/TLS/RTP/SAVPF 96"));
        assert!(a.contains("a=recvonly"));
        assert!(a.contains("a=ice-ufrag:uf"));
        assert!(a.contains("a=fingerprint:sha-256 11:22"));
        assert!(a.contains("a=rtcp-fb:96 nack pli"));
    }
}