feat: WHIP support

This commit is contained in:
2026-08-07 11:48:31 +01:00
parent c1435a70fb
commit 5f152e9db7
3 changed files with 349 additions and 189 deletions
+160 -148
View File
@@ -64,29 +64,19 @@ pub async fn handle_whip_injest_patch(
body: String, body: String,
) -> Result<impl IntoResponse, HttpError> { ) -> Result<impl IntoResponse, HttpError> {
// The slug is the stream_key_id (set by the POST handler's Location header). // The slug is the stream_key_id (set by the POST handler's Location header).
let stream_key_id: i32 = slug let stream_key_id: i32 = slug.parse().map_err(|e| {
.parse() warn!(%slug, "Whip PATCH: bad slug: {:?}", e);
.map_err(|e| { HttpError::NotFound
warn!(%slug, "Whip PATCH: bad slug: {:?}", e); })?;
HttpError::NotFound
})?;
info!(stream_key_id, ct = ?headers.get(header::CONTENT_TYPE), "Whip PATCH: trickle candidate"); info!(stream_key_id, ct = ?headers.get(header::CONTENT_TYPE), "Whip PATCH: trickle candidate");
// Look up the trickle sender for this session. // Look up the trickle sender for this session.
let trickle_map = state let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone();
.appstate let tx = trickle_map.get(&stream_key_id).ok_or_else(|| {
.lock() warn!(stream_key_id, "Whip PATCH: no trickle channel for session");
.await HttpError::NotFound
.webrtc_proxy })?;
.trickle_tx
.clone();
let tx = trickle_map
.get(&stream_key_id)
.ok_or_else(|| {
warn!(stream_key_id, "Whip PATCH: no trickle channel for session");
HttpError::NotFound
})?;
debug!(stream_key_id, %body, "Whip PATCH: forwarding trickle candidate"); debug!(stream_key_id, %body, "Whip PATCH: forwarding trickle candidate");
tx.send(body).ok(); tx.send(body).ok();
@@ -145,113 +135,117 @@ pub async fn handle_whip_injest(
}; };
info!(stream_key_id = key.id, label = %key.label, "Whip authenticated stream key from {}", remote); info!(stream_key_id = key.id, label = %key.label, "Whip authenticated stream key from {}", remote);
// Parse the SDP offer first so we can detect the codec and // Parse the SDP offer first so we can detect the codec and
// configure the Rtc before accepting. // configure the Rtc before accepting.
let sdp_offer = match SdpOffer::from_sdp_string(&offer) { let sdp_offer = match SdpOffer::from_sdp_string(&offer) {
Ok(o) => o, Ok(o) => o,
Err(e) => { Err(e) => {
error!("Whip: cant parse offer from {}: {:?}", remote, e); error!("Whip: cant parse offer from {}: {:?}", remote, e);
return err(StatusCode::BAD_REQUEST, "invalid SDP offer"); return err(StatusCode::BAD_REQUEST, "invalid SDP offer");
}
};
info!(
"Whip offer from {} — {} media line(s):",
remote,
sdp_offer.media_lines.len()
);
for x in &sdp_offer.media_lines {
info!(" {}", x);
} }
};
info!(
"Whip offer from {} — {} media line(s):",
remote,
sdp_offer.media_lines.len()
);
for x in &sdp_offer.media_lines {
info!(" {}", x);
}
// Detect codec from the offer so we can enable matching codecs. // Detect codec from the offer so we can enable matching codecs.
let stream_codec = video_codec_from_sdp_offer(&sdp_offer); let stream_codec = video_codec_from_sdp_offer(&sdp_offer);
info!(?stream_codec, "detected codec from WHIP offer"); info!(?stream_codec, "detected codec from WHIP offer");
// Build Rtc with ICE-Lite (required by WHIP RFC 9728 §4.1) and // Build Rtc with ICE-Lite (required by WHIP RFC 9728 §4.1) and
// matching codecs enabled. // matching codecs enabled.
// Not using ICE-Lite: full ICE lets the server initiate checks // Not using ICE-Lite: full ICE lets the server initiate checks
// when OBS hasn't sent its candidates yet (Trickle ICE without PATCH). // when OBS hasn't sent its candidates yet (Trickle ICE without PATCH).
let mut builder = Rtc::builder(); let mut builder = Rtc::builder();
{ {
let cc = builder.codec_config(); let cc = builder.codec_config();
cc.clear(); cc.clear();
cc.enable_opus(true); cc.enable_opus(true);
match &stream_codec { match &stream_codec {
Some(crate::StreamCodec::H264) => { Some(crate::StreamCodec::H264) => {
info!("Whip: enabling H.264 codec"); info!("Whip: enabling H.264 codec");
cc.enable_h264(true); cc.enable_h264(true);
} }
Some(crate::StreamCodec::H265) => { Some(crate::StreamCodec::H265) => {
info!("Whip: enabling H.265 codec"); info!("Whip: enabling H.265 codec");
cc.enable_h265(true); cc.enable_h265(true);
} }
Some(crate::StreamCodec::AV1) => { Some(crate::StreamCodec::AV1) => {
info!("Whip: enabling AV1 codec"); info!("Whip: enabling AV1 codec");
cc.enable_av1(true); cc.enable_av1(true);
} }
None => { None => {
warn!("Whip: no video codec detected in offer, enabling H.264 as fallback"); warn!("Whip: no video codec detected in offer, enabling H.264 as fallback");
cc.enable_h264(true); cc.enable_h264(true);
}
} }
} }
let mut rtc = builder.build(Instant::now()); }
let candidate = Candidate::host(public_addr, Protocol::Udp).unwrap(); let mut rtc = builder.build(Instant::now());
rtc.add_local_candidate(candidate); let candidate = Candidate::host(public_addr, Protocol::Udp).unwrap();
info!(%public_addr, "Whip: added local ICE candidate, accepting offer…"); rtc.add_local_candidate(candidate);
info!(%public_addr, "Whip: added local ICE candidate, accepting offer…");
let offer_answe = match rtc.sdp_api().accept_offer(sdp_offer) { let offer_answe = match rtc.sdp_api().accept_offer(sdp_offer) {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {
error!("cant accept inject offer: {:?}", e); error!("cant accept inject offer: {:?}", e);
return err(StatusCode::BAD_REQUEST, "could not accept offer"); return err(StatusCode::BAD_REQUEST, "could not accept offer");
}
};
// OBS sends no a=candidate: lines (disableAutoGathering). Derive a
// remote host candidate from the HTTP source address so the server
// has somewhere to send STUN checks.
if let Ok(c) = Candidate::host(remote, Protocol::Udp) {
info!(%remote, "Whip: no candidates in offer, adding HTTP-derived remote host candidate");
rtc.add_remote_candidate(c);
} }
};
// Extract negotiated codec, PT, and profile from the answer // OBS sends no a=candidate: lines (disableAutoGathering). Derive a
// so WHEP viewers can use the exact same codec config. // remote host candidate from the HTTP source address so the server
let (negotiated_codec, video_pt, video_profile) = // has somewhere to send STUN checks.
extract_negotiated_codec_info(&offer_answe); if let Ok(c) = Candidate::host(remote, Protocol::Udp) {
info!( info!(%remote, "Whip: no candidates in offer, adding HTTP-derived remote host candidate");
?negotiated_codec, rtc.add_remote_candidate(c);
video_pt, }
?video_profile,
"negotiated codec from WHIP answer"
);
if negotiated_codec.is_none() || video_pt.is_none() {
warn!("Whip: no common video codec negotiated, rejecting");
return err(StatusCode::NOT_ACCEPTABLE, "no common video codec");
}
let answer_sdp = offer_answe.to_sdp_string() // Extract negotiated codec, PT, and profile from the answer
// Strip a=ice-options:trickle so OBS starts ICE immediately. // so WHEP viewers can use the exact same codec config.
.replace("a=ice-options:trickle\r\n", "") let (negotiated_codec, video_pt, video_profile) = extract_negotiated_codec_info(&offer_answe);
.replace("a=ice-options:trickle\n", ""); info!(
// Fix up the answer for libdatachannel (OBS WHIP): ?negotiated_codec,
// 1. Strip a=group:BUNDLE — OBS doesn't negotiate it. video_pt,
// 2. Add the host candidate to the video m= line. ?video_profile,
let answer_sdp = answer_sdp "negotiated codec from WHIP answer"
.replace("a=group:BUNDLE 0 1\r\n", "") );
.replace("a=group:BUNDLE 0 1\n", ""); if negotiated_codec.is_none() || video_pt.is_none() {
let answer_sdp = if let Some(cand_line) = answer_sdp warn!("Whip: no common video codec negotiated, rejecting");
.lines() return err(StatusCode::NOT_ACCEPTABLE, "no common video codec");
.find(|l| l.starts_with("a=candidate:")) }
{
let answer_sdp = offer_answe
.to_sdp_string()
// Strip a=ice-options:trickle so OBS starts ICE immediately.
.replace("a=ice-options:trickle\r\n", "")
.replace("a=ice-options:trickle\n", "");
// Fix up the answer for libdatachannel (OBS WHIP):
// 1. Strip a=group:BUNDLE — OBS doesn't negotiate it.
// 2. Add the host candidate to the video m= line.
let answer_sdp = answer_sdp
.replace("a=group:BUNDLE 0 1\r\n", "")
.replace("a=group:BUNDLE 0 1\n", "");
let answer_sdp =
if let Some(cand_line) = answer_sdp.lines().find(|l| l.starts_with("a=candidate:")) {
// Insert the candidate line after the video m= line. // Insert the candidate line after the video m= line.
let cand_replacement = format!("m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 127.0.0.1\r\n{}\r\n", cand_line); let cand_replacement = format!(
answer_sdp.replace("m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\n", &cand_replacement) "m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 {}\r\n{}\r\n",
public_addr.ip(), cand_line
);
answer_sdp.replace(
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\n",
&cand_replacement,
)
} else { } else {
answer_sdp answer_sdp
}; };
info!("Serving Whip SDP answer to {}:\n{}", remote, answer_sdp); info!("Serving Whip SDP answer to {}:\n{}", remote, answer_sdp);
let ufrag = answer_sdp let ufrag = answer_sdp
.lines() .lines()
@@ -267,12 +261,21 @@ pub async fn handle_whip_injest(
.find(|l| l.starts_with("a=ice-pwd:")) .find(|l| l.starts_with("a=ice-pwd:"))
.and_then(|l| l.strip_prefix("a=ice-pwd:")) .and_then(|l| l.strip_prefix("a=ice-pwd:"))
.map(|s| s.trim().to_string()); .map(|s| s.trim().to_string());
info!(?ufrag, ?ice_pwd, "Whip: registering ICE credentials with proxy"); info!(
?ufrag,
?ice_pwd,
"Whip: registering ICE credentials with proxy"
);
let (socket, rx) = state.appstate.lock().await.webrtc_proxy.add_client(ufrag); let (socket, rx) = state.appstate.lock().await.webrtc_proxy.add_client(ufrag);
let stream_sessions = state.appstate.lock().await.stream_sessions.clone(); let stream_sessions = state.appstate.lock().await.stream_sessions.clone();
let (video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(4); let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(32);
let (audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(4); let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(32);
// Never block the ingest loop on slow/missing viewers: overwrite the
// oldest frame instead (same as the RTMP path; the viewer re-waits for a
// keyframe on Overflowed). The undrained rx below must not stall sends.
video_tx.set_overflow(true);
audio_tx.set_overflow(true);
stream_sessions.insert( stream_sessions.insert(
key.id, key.id,
@@ -288,7 +291,10 @@ pub async fn handle_whip_injest(
video_profile_level_id: video_profile, video_profile_level_id: video_profile,
}, },
); );
info!(stream_key_id = key.id, "Whip: StreamSession inserted, spawning detach task"); info!(
stream_key_id = key.id,
"Whip: StreamSession inserted, spawning detach task"
);
// Trickle-ICE channel: OBS can send candidates via PATCH after the // Trickle-ICE channel: OBS can send candidates via PATCH after the
// initial offer. We forward them to the Rtc task for add_remote_candidate. // initial offer. We forward them to the Rtc task for add_remote_candidate.
@@ -326,7 +332,10 @@ pub async fn handle_whip_injest(
.header(header::LOCATION, &location) .header(header::LOCATION, &location)
// Provide a STUN server via Link header so OBS can gather // Provide a STUN server via Link header so OBS can gather
// ICE candidates even without explicit STUN configuration. // ICE candidates even without explicit STUN configuration.
.header(header::LINK, "<stun:stun.l.google.com:19302>; rel=\"ice-server\"") .header(
header::LINK,
"<stun:stun.l.google.com:19302>; rel=\"ice-server\"",
)
.body(axum::body::Body::from(answer_sdp)) .body(axum::body::Body::from(answer_sdp))
.unwrap() .unwrap()
} }
@@ -372,14 +381,14 @@ async fn detach_inject_rtc(
let deadline = loop { let deadline = loop {
match rtc.poll_output() { match rtc.poll_output() {
Ok(Output::Timeout(t)) => break t, Ok(Output::Timeout(t)) => break t,
Ok(Output::Transmit(t)) => { Ok(Output::Transmit(t)) => {
debug!( debug!(
"Whip TX: {} bytes → {}:{}", "Whip TX: {} bytes → {}:{}",
t.contents.len(), t.contents.len(),
t.destination.ip(), t.destination.ip(),
t.destination.port() t.destination.port()
); );
if let Err(e) = socket.send_to(&t.contents, t.destination).await { if let Err(e) = socket.send_to(&t.contents, t.destination).await {
warn!("Whip UDP send error: {:?}", e); warn!("Whip UDP send error: {:?}", e);
cleanup().await; cleanup().await;
return; return;
@@ -430,7 +439,10 @@ async fn detach_inject_rtc(
} }
} }
Event::Connected => { Event::Connected => {
info!(stream_key_id, "Whip DTLS+ICE connected, wiring broadcast channels"); info!(
stream_key_id,
"Whip DTLS+ICE connected, wiring broadcast channels"
);
if let Some(session) = sessions_ref.get(&stream_key_id) { if let Some(session) = sessions_ref.get(&stream_key_id) {
video_tx = Some(session.frame_channel.clone()); video_tx = Some(session.frame_channel.clone());
audio_tx = Some(session.audio_channel.clone()); audio_tx = Some(session.audio_channel.clone());
@@ -448,7 +460,7 @@ async fn detach_inject_rtc(
}; };
let sleep = tokio::time::sleep_until(deadline.max(Instant::now()).into()); let sleep = tokio::time::sleep_until(deadline.max(Instant::now()).into());
tokio::select! { tokio::select! {
_ = sleep => { _ = sleep => {
if let Err(e) = rtc.handle_input(Input::Timeout(Instant::now())) { if let Err(e) = rtc.handle_input(Input::Timeout(Instant::now())) {
error!(stream_key_id, "Whip handle_input(Timeout) error: {:?}", e); error!(stream_key_id, "Whip handle_input(Timeout) error: {:?}", e);
@@ -510,28 +522,28 @@ async fn detach_inject_rtc(
/// Walks the answer's media lines, finds the first video m-line with /// Walks the answer's media lines, finds the first video m-line with
/// negotiated rtp_params, and returns the codec + PT + H.264 profile. /// negotiated rtp_params, and returns the codec + PT + H.264 profile.
pub fn extract_negotiated_codec_info( pub fn extract_negotiated_codec_info(
answer: &str0m::change::SdpAnswer, answer: &str0m::change::SdpAnswer,
) -> (Option<crate::StreamCodec>, Option<u8>, Option<u32>) { ) -> (Option<crate::StreamCodec>, Option<u8>, Option<u32>) {
use str0m::format::Codec; use str0m::format::Codec;
for line in answer.media_lines.iter() { for line in answer.media_lines.iter() {
// Iterate rtp_params on every m-line; video check via // Iterate rtp_params on every m-line; video check via
// codec.is_video() avoids needing str0m's private MediaType. // codec.is_video() avoids needing str0m's private MediaType.
for p in line.rtp_params() { for p in line.rtp_params() {
if p.spec().codec.is_video() { if p.spec().codec.is_video() {
let pt = Some(*p.pt()); let pt = Some(*p.pt());
let profile = p.spec().format.profile_level_id; let profile = p.spec().format.profile_level_id;
let codec = match p.spec().codec { let codec = match p.spec().codec {
Codec::H264 => Some(crate::StreamCodec::H264), Codec::H264 => Some(crate::StreamCodec::H264),
Codec::H265 => Some(crate::StreamCodec::H265), Codec::H265 => Some(crate::StreamCodec::H265),
Codec::Av1 => Some(crate::StreamCodec::AV1), Codec::Av1 => Some(crate::StreamCodec::AV1),
_ => None, _ => None,
}; };
return (codec, pt, profile); return (codec, pt, profile);
} }
} }
} }
(None, None, None) (None, None, None)
} }
/// Extract the video codec from an SDP offer's media lines. /// Extract the video codec from an SDP offer's media lines.
+71 -41
View File
@@ -36,19 +36,31 @@ impl WebrtcProxy {
let sock = UdpSocket::bind(format!("0.0.0.0:{}", config.proxy_port)).await?; let sock = UdpSocket::bind(format!("0.0.0.0:{}", config.proxy_port)).await?;
let port = sock.local_addr()?.port(); let port = sock.local_addr()?.port();
let public_ip = match env::var("PUBLIC_DOMAIN") { let public_ip = match env::var("PUBLIC_DOMAIN")
Ok(domain) => { .ok()
.filter(|s| !s.trim().is_empty())
{
Some(domain) => {
let ip = resolve_domain(&domain).await?; let ip = resolve_domain(&domain).await?;
info!(%domain, %ip, "resolved PUBLIC_DOMAIN for WebRTC candidates"); info!(%domain, %ip, "resolved PUBLIC_DOMAIN for WebRTC candidates");
ip ip
} }
Err(_) => { None => {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
// For testing // For testing — advertise a real interface IP. Loopback is
info!( // unreachable from clients whose ICE stack pins its UDP
"using ip 0.0.0.0 for WebRTC candidates (because we are in a debug build)" // sockets to a specific interface (OBS sets
); // IP_UNICAST_IF), which makes checks to 127.0.0.1 vanish.
IpAddr::from([127, 0, 0, 1]) match default_iface_ipv4() {
Some(ip) => {
info!(%ip, "using default interface IP for WebRTC candidates (debug build)");
ip
}
None => {
warn!("no non-loopback IPv4 interface found, falling back to 127.0.0.1");
IpAddr::from([127, 0, 0, 1])
}
}
} else { } else {
let ip = stun_public_ip().await?; let ip = stun_public_ip().await?;
info!(%ip, "discovered public IP via STUN for WebRTC candidates"); info!(%ip, "discovered public IP via STUN for WebRTC candidates");
@@ -83,9 +95,14 @@ impl WebrtcProxy {
}; };
let data = Bytes::copy_from_slice(&buf[..b]); let data = Bytes::copy_from_slice(&buf[..b]);
// By addr // By addr
if let Some(tx) = by_addr.get(&from) { if let Some(tx) = by_addr.get(&from) {
debug!("proxy: routing {} bytes by addr {}:{} → channel", b, from.ip(), from.port()); debug!(
"proxy: routing {} bytes by addr {}:{} → channel",
b,
from.ip(),
from.port()
);
match tx.try_send((data, from)) { match tx.try_send((data, from)) {
Ok(_) => continue, Ok(_) => continue,
Err(e) => { Err(e) => {
@@ -110,16 +127,22 @@ impl WebrtcProxy {
// might be a response to OUR STUN request (remote:local) // might be a response to OUR STUN request (remote:local)
// or an incoming request from the remote peer (local:remote). // or an incoming request from the remote peer (local:remote).
let part2_lookup = part2.clone(); let part2_lookup = part2.clone();
let entry = by_ufrag.remove(&part1).or_else(|| { let entry = by_ufrag
part2_lookup.and_then(|p2| by_ufrag.remove(&p2)) .remove(&part1)
}); .or_else(|| part2_lookup.and_then(|p2| by_ufrag.remove(&p2)));
let Some((_, tx)) = entry else { let Some((_, tx)) = entry else {
warn!("STUN packet ({}/{:?}), isnt registored", part1, part2); warn!("STUN packet ({}/{:?}), isnt registored", part1, part2);
continue; continue;
}; };
by_addr.insert(from, tx.clone()); by_addr.insert(from, tx.clone());
info!("proxy: STUN match → promoted {} → ufrag={} (match was {}/{})", from, part1, part1, part2.as_deref().unwrap_or("-")); info!(
"proxy: STUN match → promoted {} → ufrag={} (match was {}/{})",
from,
part1,
part1,
part2.as_deref().unwrap_or("-")
);
debug!("sending data"); debug!("sending data");
if let Err(e) = tx.try_send((data, from)) { if let Err(e) = tx.try_send((data, from)) {
match e { match e {
@@ -148,33 +171,40 @@ impl WebrtcProxy {
self.public_addr self.public_addr
} }
pub fn ufrag_pair(b: &Bytes) -> Option<(String, Option<String>)> { pub fn ufrag_pair(b: &Bytes) -> Option<(String, Option<String>)> {
if b.len() <= 20 { if b.len() <= 20 {
return None; return None;
}
let magic = u32::from_be_bytes(b[4..8].try_into().ok()?);
if magic != STUN_MAGIC {
return None;
}
// attribies start at 20
let mut pos = 20usize;
while (pos + 4) <= b.len() {
let attr_type: u16 = u16::from_be_bytes(b[pos..pos + 2].try_into().ok()?);
let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?);
pos += 4;
if attr_type == 0x0006 {
let value = std::str::from_utf8(
b[pos..pos + (attr_len as usize)].try_into().ok()?,
)
.ok()?;
let mut parts = value.split(':');
let first = parts.next()?.to_string();
let second = parts.next().map(|s| s.to_string());
return Some((first, second));
}
pos += (attr_len as usize + 3) & !3;
}
None
} }
let magic = u32::from_be_bytes(b[4..8].try_into().ok()?);
if magic != STUN_MAGIC {
return None;
}
// attribies start at 20
let mut pos = 20usize;
while (pos + 4) <= b.len() {
let attr_type: u16 = u16::from_be_bytes(b[pos..pos + 2].try_into().ok()?);
let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?);
pos += 4;
if attr_type == 0x0006 {
let value =
std::str::from_utf8(b[pos..pos + (attr_len as usize)].try_into().ok()?).ok()?;
let mut parts = value.split(':');
let first = parts.next()?.to_string();
let second = parts.next().map(|s| s.to_string());
return Some((first, second));
}
pos += (attr_len as usize + 3) & !3;
}
None
}
}
/// IP of the interface holding the default route, via a UDP connect() trick:
/// connect() only does a route lookup (no packets sent), so the kernel binds
/// the source address the OS would use for outbound traffic.
fn default_iface_ipv4() -> Option<IpAddr> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:9").ok()?;
sock.local_addr().ok().map(|a| a.ip())
} }
async fn resolve_domain(domain: &str) -> Result<std::net::IpAddr, Box<dyn Error>> { async fn resolve_domain(domain: &str) -> Result<std::net::IpAddr, Box<dyn Error>> {
+118
View File
@@ -0,0 +1,118 @@
//! Temporary probe: replicate handle_whip_injest's Rtc setup, feed it a
//! realistic OBS/libdatachannel WHIP offer, print the answer SDP and check
//! whether the string-fixups in webrtc_ingest.rs actually match.
use std::{net::SocketAddr, time::Instant};
use str0m::{change::SdpOffer, net::Protocol, Candidate, Rtc};
fn obs_like_offer() -> String {
let mut s = String::new();
s.push_str("v=0\r\n");
s.push_str("o=- 4527835755568137757 2 IN IP4 127.0.0.1\r\n");
s.push_str("s=-\r\n");
s.push_str("t=0 0\r\n");
s.push_str("a=group:BUNDLE 0 1\r\n");
s.push_str("a=msid-semantic: WMS *\r\n");
s.push_str("m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n");
s.push_str("c=IN IP4 0.0.0.0\r\n");
s.push_str("a=rtcp:9 IN IP4 0.0.0.0\r\n");
s.push_str("a=ice-ufrag:obs_ufrag_audio\r\n");
s.push_str("a=ice-pwd:obs_pwd_audio\r\n");
s.push_str("a=ice-options:trickle\r\n");
s.push_str("a=fingerprint:sha-256 5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A\r\n");
s.push_str("a=setup:actpass\r\n");
s.push_str("a=mid:0\r\n");
s.push_str("a=sendrecv\r\n");
s.push_str("a=rtcp-mux\r\n");
s.push_str("a=rtpmap:111 opus/48000/2\r\n");
s.push_str("a=rtcp-fb:111 transport-cc\r\n");
s.push_str("a=fmtp:111 minptime=10;useinbandfec=1\r\n");
s.push_str("m=video 9 UDP/TLS/RTP/SAVPF 96\r\n");
s.push_str("c=IN IP4 0.0.0.0\r\n");
s.push_str("a=rtcp:9 IN IP4 0.0.0.0\r\n");
s.push_str("a=ice-ufrag:obs_ufrag_video\r\n");
s.push_str("a=ice-pwd:obs_pwd_video\r\n");
s.push_str("a=ice-options:trickle\r\n");
s.push_str("a=fingerprint:sha-256 5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A:6B:7C:8D:9E:AF:B0:C1:D2:E3:F4:05:16:27:38:49:5A\r\n");
s.push_str("a=setup:actpass\r\n");
s.push_str("a=mid:1\r\n");
s.push_str("a=sendrecv\r\n");
s.push_str("a=rtcp-mux\r\n");
s.push_str("a=rtpmap:96 H264/90000\r\n");
s.push_str("a=rtcp-fb:96 nack\r\n");
s.push_str("a=rtcp-fb:96 nack pli\r\n");
s.push_str("a=rtcp-fb:96 transport-cc\r\n");
s.push_str(
"a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r\n",
);
s
}
#[test]
fn probe_whip_answer() {
let offer_sdp = obs_like_offer();
let sdp_offer = SdpOffer::from_sdp_string(&offer_sdp).expect("parse offer");
// Mirror handle_whip_injest's builder setup.
let mut builder = Rtc::builder();
{
let cc = builder.codec_config();
cc.clear();
cc.enable_opus(true);
cc.enable_h264(true);
}
let mut rtc = builder.build(Instant::now());
let public_addr: SocketAddr = "203.0.113.7:6969".parse().unwrap();
let candidate = Candidate::host(public_addr, Protocol::Udp).unwrap();
rtc.add_local_candidate(candidate);
let answer = rtc.sdp_api().accept_offer(sdp_offer).expect("accept offer");
let answer_sdp = answer.to_sdp_string();
println!("=== RAW ANSWER ===");
println!("{answer_sdp}");
println!("=== END RAW ANSWER ===");
// Now replicate the fixups from webrtc_ingest.rs verbatim.
let answer_sdp = answer_sdp
.replace("a=ice-options:trickle\r\n", "")
.replace("a=ice-options:trickle\n", "");
let answer_sdp = answer_sdp
.replace("a=group:BUNDLE 0 1\r\n", "")
.replace("a=group:BUNDLE 0 1\n", "");
let answer_sdp =
if let Some(cand_line) = answer_sdp.lines().find(|l| l.starts_with("a=candidate:")) {
let cand_replacement = format!(
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 127.0.0.1\r\n{}\r\n",
cand_line
);
answer_sdp.replace(
"m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\n",
&cand_replacement,
)
} else {
answer_sdp
};
println!("=== FIXED ANSWER ===");
println!("{answer_sdp}");
println!("=== END FIXED ANSWER ===");
// Diagnostics
let video_mline = answer_sdp
.lines()
.find(|l| l.starts_with("m=video"))
.unwrap();
println!("video m-line after fixup: {video_mline}");
println!(
"has a=candidate after fixup: {}",
answer_sdp.lines().any(|l| l.starts_with("a=candidate:"))
);
println!(
"a=candidate lines: {:?}",
answer_sdp
.lines()
.filter(|l| l.starts_with("a=candidate:"))
.collect::<Vec<_>>()
);
}