feat: WHIP support
This commit is contained in:
@@ -64,9 +64,7 @@ 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()
|
|
||||||
.map_err(|e| {
|
|
||||||
warn!(%slug, "Whip PATCH: bad slug: {:?}", e);
|
warn!(%slug, "Whip PATCH: bad slug: {:?}", e);
|
||||||
HttpError::NotFound
|
HttpError::NotFound
|
||||||
})?;
|
})?;
|
||||||
@@ -74,16 +72,8 @@ 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()
|
|
||||||
.await
|
|
||||||
.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");
|
warn!(stream_key_id, "Whip PATCH: no trickle channel for session");
|
||||||
HttpError::NotFound
|
HttpError::NotFound
|
||||||
})?;
|
})?;
|
||||||
@@ -218,8 +208,7 @@ pub async fn handle_whip_injest(
|
|||||||
|
|
||||||
// Extract negotiated codec, PT, and profile from the answer
|
// Extract negotiated codec, PT, and profile from the answer
|
||||||
// so WHEP viewers can use the exact same codec config.
|
// so WHEP viewers can use the exact same codec config.
|
||||||
let (negotiated_codec, video_pt, video_profile) =
|
let (negotiated_codec, video_pt, video_profile) = extract_negotiated_codec_info(&offer_answe);
|
||||||
extract_negotiated_codec_info(&offer_answe);
|
|
||||||
info!(
|
info!(
|
||||||
?negotiated_codec,
|
?negotiated_codec,
|
||||||
video_pt,
|
video_pt,
|
||||||
@@ -231,7 +220,8 @@ pub async fn handle_whip_injest(
|
|||||||
return err(StatusCode::NOT_ACCEPTABLE, "no common video codec");
|
return err(StatusCode::NOT_ACCEPTABLE, "no common video codec");
|
||||||
}
|
}
|
||||||
|
|
||||||
let answer_sdp = offer_answe.to_sdp_string()
|
let answer_sdp = offer_answe
|
||||||
|
.to_sdp_string()
|
||||||
// Strip a=ice-options:trickle so OBS starts ICE immediately.
|
// Strip a=ice-options:trickle so OBS starts ICE immediately.
|
||||||
.replace("a=ice-options:trickle\r\n", "")
|
.replace("a=ice-options:trickle\r\n", "")
|
||||||
.replace("a=ice-options:trickle\n", "");
|
.replace("a=ice-options:trickle\n", "");
|
||||||
@@ -241,13 +231,17 @@ pub async fn handle_whip_injest(
|
|||||||
let answer_sdp = answer_sdp
|
let answer_sdp = answer_sdp
|
||||||
.replace("a=group:BUNDLE 0 1\r\n", "")
|
.replace("a=group:BUNDLE 0 1\r\n", "")
|
||||||
.replace("a=group:BUNDLE 0 1\n", "");
|
.replace("a=group:BUNDLE 0 1\n", "");
|
||||||
let answer_sdp = if let Some(cand_line) = answer_sdp
|
let answer_sdp =
|
||||||
.lines()
|
if let Some(cand_line) = answer_sdp.lines().find(|l| l.starts_with("a=candidate:")) {
|
||||||
.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
|
||||||
};
|
};
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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.
|
||||||
|
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])
|
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");
|
||||||
@@ -85,7 +97,12 @@ impl WebrtcProxy {
|
|||||||
|
|
||||||
// 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 {
|
||||||
@@ -162,10 +185,8 @@ impl WebrtcProxy {
|
|||||||
let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?);
|
let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?);
|
||||||
pos += 4;
|
pos += 4;
|
||||||
if attr_type == 0x0006 {
|
if attr_type == 0x0006 {
|
||||||
let value = std::str::from_utf8(
|
let value =
|
||||||
b[pos..pos + (attr_len as usize)].try_into().ok()?,
|
std::str::from_utf8(b[pos..pos + (attr_len as usize)].try_into().ok()?).ok()?;
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
let mut parts = value.split(':');
|
let mut parts = value.split(':');
|
||||||
let first = parts.next()?.to_string();
|
let first = parts.next()?.to_string();
|
||||||
let second = parts.next().map(|s| s.to_string());
|
let second = parts.next().map(|s| s.to_string());
|
||||||
@@ -177,6 +198,15 @@ impl WebrtcProxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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>> {
|
||||||
let addr = tokio::net::lookup_host(format!("{}:0", domain))
|
let addr = tokio::net::lookup_host(format!("{}:0", domain))
|
||||||
.await?
|
.await?
|
||||||
|
|||||||
@@ -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<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user