File
Blob: firmware/vendor/str0m/src/sdp/data.rs
| 1 | #![allow(clippy::single_match)] |
| 2 | |
| 3 | use combine::EasyParser; |
| 4 | use std::collections::HashMap; |
| 5 | use std::collections::HashSet; |
| 6 | use std::fmt::{self}; |
| 7 | use std::ops::Deref; |
| 8 | |
| 9 | use crate::crypto::Fingerprint; |
| 10 | use crate::format::Codec; |
| 11 | use crate::format::CodecSpec; |
| 12 | use crate::format::FormatParams; |
| 13 | use crate::format::PayloadParams; |
| 14 | use crate::packet::H265ProfileTierLevel; |
| 15 | use crate::packet::H266ProfileTierLevel; |
| 16 | use crate::rtp_::{Direction, Extension, Frequency, Mid, Pt, Rid, SessionId, Ssrc}; |
| 17 | use crate::{Candidate, IceCreds, VERSION}; |
| 18 | use str0m_proto::Id; |
| 19 | |
| 20 | use super::SdpError; |
| 21 | use super::parser::sdp_parser; |
| 22 | |
| 23 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 24 | pub struct Sdp { |
| 25 | pub session: Session, |
| 26 | pub media_lines: Vec<MediaLine>, |
| 27 | } |
| 28 | |
| 29 | impl Sdp { |
| 30 | pub(crate) fn parse(input: &str) -> Result<Sdp, SdpError> { |
| 31 | sdp_parser() |
| 32 | .easy_parse(input) |
| 33 | .map(|(sdp, _)| sdp) |
| 34 | .map_err(|e| SdpError::ParseError(e.to_string())) |
| 35 | } |
| 36 | |
| 37 | /// Get the MIDs listed in the BUNDLE group, if any. |
| 38 | pub(crate) fn bundle_mids(&self) -> Option<&[Mid]> { |
| 39 | self.session.attrs.iter().find_map(|a| { |
| 40 | if let SessionAttribute::Group { typ, mids } = a { |
| 41 | if typ == "BUNDLE" { |
| 42 | return Some(mids.as_slice()); |
| 43 | } |
| 44 | } |
| 45 | None |
| 46 | }) |
| 47 | } |
| 48 | |
| 49 | pub(crate) fn assert_consistency(&self) -> Result<(), SdpError> { |
| 50 | match self.do_assert_consistency() { |
| 51 | None => Ok(()), |
| 52 | Some(error) => Err(SdpError::Inconsistent(error)), |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | pub(crate) fn fingerprint(&self) -> Option<Fingerprint> { |
| 57 | self.session |
| 58 | .fingerprint() |
| 59 | .or_else(|| self.media_lines.iter().find_map(|m| m.fingerprint())) |
| 60 | } |
| 61 | |
| 62 | pub(crate) fn ice_creds(&self) -> Option<IceCreds> { |
| 63 | self.session |
| 64 | .ice_creds() |
| 65 | .or_else(|| self.media_lines.iter().find_map(|m| m.ice_creds())) |
| 66 | } |
| 67 | |
| 68 | pub(crate) fn ice_candidates(&self) -> impl Iterator<Item = &Candidate> { |
| 69 | let mut candidates: HashSet<&Candidate> = HashSet::new(); |
| 70 | |
| 71 | // Session level ice candidates. |
| 72 | candidates.extend(self.session.ice_candidates()); |
| 73 | |
| 74 | // Ice candidates. |
| 75 | for m in &self.media_lines { |
| 76 | candidates.extend(m.ice_candidates()); |
| 77 | } |
| 78 | |
| 79 | candidates.into_iter() |
| 80 | } |
| 81 | |
| 82 | /// Get the `a=sctp-init` value from the application m-line, if present. |
| 83 | /// |
| 84 | /// Returns the base64-encoded SCTP INIT value. |
| 85 | pub(crate) fn sctp_init(&self) -> Option<&str> { |
| 86 | self.media_lines |
| 87 | .iter() |
| 88 | .find(|m| m.typ.is_channel()) |
| 89 | .and_then(|m| m.sctp_init()) |
| 90 | } |
| 91 | |
| 92 | pub(crate) fn setup(&self) -> Option<Setup> { |
| 93 | self.session |
| 94 | .setup() |
| 95 | .or_else(|| self.media_lines.iter().find_map(|m| m.setup())) |
| 96 | } |
| 97 | |
| 98 | fn do_assert_consistency(&self) -> Option<String> { |
| 99 | // TODO: SDP assertions we need to make: |
| 100 | // 1. Ensure that every m-line has the same PT configuration for a codec. I.e. if FIR is enabled |
| 101 | // for PT 96 in one m-line it must be in all m-lines. |
| 102 | // 2. Compare with previous m-lines that remote isn't narrowing/expanding PT and/or extmaps. |
| 103 | |
| 104 | let group = self |
| 105 | .session |
| 106 | .attrs |
| 107 | .iter() |
| 108 | .find(|a| matches!(a, SessionAttribute::Group { .. })); |
| 109 | |
| 110 | if let Some(SessionAttribute::Group { mids, .. }) = group { |
| 111 | if !mids.len() == self.media_lines.len() { |
| 112 | return Some(format!( |
| 113 | "a=group mid count doesn't match m-line count {} != {}", |
| 114 | mids.len(), |
| 115 | self.media_lines.len() |
| 116 | )); |
| 117 | } |
| 118 | for (media, mid) in self.media_lines.iter().zip(mids.iter()) { |
| 119 | media.check_consistent()?; |
| 120 | let m = media.mid(); |
| 121 | if m != *mid { |
| 122 | return Some(format!("Mid order not matching a=group {m} != {mid}")); |
| 123 | } |
| 124 | } |
| 125 | } else { |
| 126 | return Some("Session attribute a=group missing".into()); |
| 127 | } |
| 128 | |
| 129 | None |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | /// Session info, before the first m= line |
| 134 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 135 | pub struct Session { |
| 136 | pub id: SessionId, |
| 137 | pub bw: Option<Bandwidth>, |
| 138 | pub attrs: Vec<SessionAttribute>, |
| 139 | } |
| 140 | |
| 141 | /// Bandwidth from b= line |
| 142 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 143 | pub struct Bandwidth { |
| 144 | pub typ: String, |
| 145 | pub val: String, |
| 146 | } |
| 147 | |
| 148 | impl Session { |
| 149 | pub fn setup(&self) -> Option<Setup> { |
| 150 | let setup = self.attrs.iter().find_map(|m| { |
| 151 | if let SessionAttribute::Setup(v) = m { |
| 152 | Some(v) |
| 153 | } else { |
| 154 | None |
| 155 | } |
| 156 | })?; |
| 157 | |
| 158 | Some(*setup) |
| 159 | } |
| 160 | |
| 161 | pub fn ice_creds(&self) -> Option<IceCreds> { |
| 162 | let ufrag = self.attrs.iter().find_map(|m| { |
| 163 | if let SessionAttribute::IceUfrag(v) = m { |
| 164 | Some(v) |
| 165 | } else { |
| 166 | None |
| 167 | } |
| 168 | })?; |
| 169 | |
| 170 | let pass = self.attrs.iter().find_map(|m| { |
| 171 | if let SessionAttribute::IcePwd(v) = m { |
| 172 | Some(v) |
| 173 | } else { |
| 174 | None |
| 175 | } |
| 176 | })?; |
| 177 | |
| 178 | Some(IceCreds { |
| 179 | ufrag: ufrag.to_string(), |
| 180 | pass: pass.to_string(), |
| 181 | }) |
| 182 | } |
| 183 | |
| 184 | pub fn fingerprint(&self) -> Option<Fingerprint> { |
| 185 | for a in &self.attrs { |
| 186 | if let SessionAttribute::Fingerprint(v) = a { |
| 187 | return Some(v.clone()); |
| 188 | } |
| 189 | } |
| 190 | None |
| 191 | } |
| 192 | |
| 193 | pub fn ice_lite(&self) -> bool { |
| 194 | self.attrs |
| 195 | .iter() |
| 196 | .any(|a| matches!(a, SessionAttribute::IceLite)) |
| 197 | } |
| 198 | |
| 199 | pub fn ice_candidates(&self) -> impl Iterator<Item = &Candidate> { |
| 200 | self.attrs.iter().filter_map(|a| { |
| 201 | if let SessionAttribute::Candidate(v) = a { |
| 202 | Some(v) |
| 203 | } else { |
| 204 | None |
| 205 | } |
| 206 | }) |
| 207 | } |
| 208 | |
| 209 | pub fn end_of_candidates(&self) -> bool { |
| 210 | self.attrs |
| 211 | .iter() |
| 212 | .any(|a| matches!(a, SessionAttribute::EndOfCandidates)) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | /// Attributes before the first m= line. |
| 217 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 218 | pub enum SessionAttribute { |
| 219 | Group { |
| 220 | typ: String, // BUNDLE, LS etc |
| 221 | mids: Vec<Mid>, // 0 1 2 3 |
| 222 | }, |
| 223 | MsidSemantic { |
| 224 | semantic: String, // WMS |
| 225 | stream_ids: Vec<String>, |
| 226 | }, |
| 227 | AllowMixedExts, |
| 228 | IceLite, |
| 229 | IceUfrag(String), |
| 230 | IcePwd(String), |
| 231 | IceOptions(String), |
| 232 | Fingerprint(Fingerprint), |
| 233 | Setup(Setup), // active, passive, actpass, holdconn |
| 234 | Candidate(Candidate), |
| 235 | EndOfCandidates, |
| 236 | Unused(String), |
| 237 | } |
| 238 | |
| 239 | fn is_dir(a: &MediaAttribute) -> bool { |
| 240 | use MediaAttribute::*; |
| 241 | matches!(a, SendRecv | SendOnly | RecvOnly | Inactive) |
| 242 | } |
| 243 | |
| 244 | /// An m-line |
| 245 | #[derive(Debug, Default, Clone, PartialEq, Eq)] |
| 246 | pub struct MediaLine { |
| 247 | pub typ: MediaType, |
| 248 | pub disabled: bool, |
| 249 | pub proto: Proto, |
| 250 | pub pts: Vec<Pt>, // payload types 96 97 125 107 from the m= line |
| 251 | pub bw: Option<Bandwidth>, |
| 252 | pub attrs: Vec<MediaAttribute>, |
| 253 | } |
| 254 | |
| 255 | impl MediaLine { |
| 256 | pub fn mid(&self) -> Mid { |
| 257 | self.attrs |
| 258 | .iter() |
| 259 | .find_map(|a| { |
| 260 | if let MediaAttribute::Mid(m) = a { |
| 261 | Some(*m) |
| 262 | } else { |
| 263 | None |
| 264 | } |
| 265 | }) |
| 266 | // We should only use `mid()` once we're certain there is |
| 267 | // a mid line. This is checked by `check_consistent`. |
| 268 | .expect("missing a=mid") |
| 269 | } |
| 270 | |
| 271 | /// Get the sctp-init attribute value (base64-encoded string). |
| 272 | /// |
| 273 | /// Returns `None` if no `a=sctp-init` attribute is present. |
| 274 | pub fn sctp_init(&self) -> Option<&str> { |
| 275 | self.attrs.iter().find_map(|a| { |
| 276 | if let MediaAttribute::SctpInit(v) = a { |
| 277 | Some(v.as_str()) |
| 278 | } else { |
| 279 | None |
| 280 | } |
| 281 | }) |
| 282 | } |
| 283 | |
| 284 | pub fn msid(&self) -> Option<Msid> { |
| 285 | self.attrs.iter().find_map(|a| { |
| 286 | if let MediaAttribute::Msid(m) = a { |
| 287 | Some(m.clone()) |
| 288 | } else { |
| 289 | None |
| 290 | } |
| 291 | }) |
| 292 | } |
| 293 | |
| 294 | pub fn direction(&self) -> Direction { |
| 295 | for a in &self.attrs { |
| 296 | match a { |
| 297 | MediaAttribute::SendRecv => return Direction::SendRecv, |
| 298 | MediaAttribute::SendOnly => return Direction::SendOnly, |
| 299 | MediaAttribute::RecvOnly => return Direction::RecvOnly, |
| 300 | MediaAttribute::Inactive => return Direction::Inactive, |
| 301 | _ => {} |
| 302 | } |
| 303 | } |
| 304 | // Should we error here? |
| 305 | Direction::Inactive |
| 306 | } |
| 307 | |
| 308 | pub fn set_direction(&mut self, dir: Direction) { |
| 309 | let idx = self |
| 310 | .attrs |
| 311 | .iter() |
| 312 | .position(is_dir) |
| 313 | .expect("m-line must have direction"); |
| 314 | self.attrs[idx] = dir.into(); |
| 315 | } |
| 316 | |
| 317 | pub fn rtp_params(&self) -> Vec<PayloadParams> { |
| 318 | let mut rtp_maps: Vec<_> = self |
| 319 | .attrs |
| 320 | .iter() |
| 321 | .filter_map(|a| { |
| 322 | if let MediaAttribute::RtpMap { pt, value: c } = a { |
| 323 | Some((*pt, *c)) |
| 324 | } else { |
| 325 | None |
| 326 | } |
| 327 | }) |
| 328 | .collect(); |
| 329 | |
| 330 | for pt in &self.pts { |
| 331 | if !rtp_maps.iter().any(|(mapped, _)| mapped == pt) { |
| 332 | if let Some(spec) = CodecSpec::from_static_pt(*pt) { |
| 333 | rtp_maps.push((*pt, spec.into())); |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | let fmtps: Vec<_> = self |
| 339 | .attrs |
| 340 | .iter() |
| 341 | .filter_map(|a| { |
| 342 | if let MediaAttribute::Fmtp { pt, values } = a { |
| 343 | Some((pt, values)) |
| 344 | } else { |
| 345 | None |
| 346 | } |
| 347 | }) |
| 348 | .collect(); |
| 349 | |
| 350 | let fbs: Vec<_> = self |
| 351 | .attrs |
| 352 | .iter() |
| 353 | .filter_map(|a| { |
| 354 | if let MediaAttribute::RtcpFb { pt, value } = a { |
| 355 | Some((pt, value)) |
| 356 | } else { |
| 357 | None |
| 358 | } |
| 359 | }) |
| 360 | .collect(); |
| 361 | |
| 362 | let mut params: Vec<_> = rtp_maps |
| 363 | .iter() |
| 364 | .filter(|(_, c)| c.codec.is_audio() | c.codec.is_video()) |
| 365 | .map(|(pt, c)| PayloadParams::new(*pt, None, (*c).into())) |
| 366 | .collect(); |
| 367 | |
| 368 | for p in &mut params { |
| 369 | for (pt, values) in fmtps.iter() { |
| 370 | // find matching a=fmtp line, if it exists. |
| 371 | if **pt == p.pt { |
| 372 | for param in values.iter() { |
| 373 | p.spec.format.set_param(param); |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | // find resend pt, if there is one. |
| 378 | for fp in values.iter() { |
| 379 | if let FormatParam::Apt(v) = fp { |
| 380 | if *v == p.pt { |
| 381 | // ensure this is a rtx |
| 382 | let is_rtx = rtp_maps |
| 383 | .iter() |
| 384 | .any(|(cpt, c)| cpt == *pt && c.codec == Codec::Rtx); |
| 385 | if is_rtx { |
| 386 | p.resend = Some(**pt); |
| 387 | } |
| 388 | } |
| 389 | } |
| 390 | } |
| 391 | } |
| 392 | |
| 393 | // rtcp feedback mechanisms |
| 394 | for (pt, value) in fbs.iter() { |
| 395 | if **pt == p.pt { |
| 396 | match &value[..] { |
| 397 | "goog-remb" => { |
| 398 | p.fb_remb = true; |
| 399 | } |
| 400 | "transport-cc" => { |
| 401 | p.fb_transport_cc = true; |
| 402 | } |
| 403 | "ccm fir" => { |
| 404 | p.fb_fir = true; |
| 405 | } |
| 406 | "nack" => { |
| 407 | p.fb_nack = true; |
| 408 | } |
| 409 | "nack pli" => { |
| 410 | p.fb_pli = true; |
| 411 | } |
| 412 | _ => { |
| 413 | // |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | params |
| 421 | } |
| 422 | |
| 423 | pub fn check_consistent(&self) -> Option<String> { |
| 424 | use MediaAttribute::*; |
| 425 | |
| 426 | let mid_count = self.attrs.iter().filter(|a| matches!(a, Mid(_))).count(); |
| 427 | |
| 428 | if mid_count == 0 { |
| 429 | return Some(format!( |
| 430 | "Media is missing a=mid: {} {}", |
| 431 | self.typ, self.proto |
| 432 | )); |
| 433 | } |
| 434 | |
| 435 | if mid_count > 1 { |
| 436 | return Some(format!( |
| 437 | "Media has more than one a=mid: {} {}", |
| 438 | self.typ, self.proto |
| 439 | )); |
| 440 | } |
| 441 | |
| 442 | let setup = self.attrs.iter().filter(|a| matches!(a, Setup(_))).count(); |
| 443 | |
| 444 | if setup > 1 { |
| 445 | return Some(format!( |
| 446 | "Expected 0 or 1 a=setup: line for mid: {}", |
| 447 | self.mid() |
| 448 | )); |
| 449 | } |
| 450 | |
| 451 | let dir_count = self.attrs.iter().filter(|a| is_dir(a)).count(); |
| 452 | |
| 453 | if self.proto == Proto::Srtp && dir_count != 1 { |
| 454 | return Some(format!( |
| 455 | "Expected exactly one of a=sendrecv, a=sendonly, a=recvonly, a=inactive for mid: {}", |
| 456 | self.mid() |
| 457 | )); |
| 458 | } |
| 459 | |
| 460 | if self.proto == Proto::Srtp && self.pts.is_empty() { |
| 461 | return Some(format!("Expected at least one PT for mid: {}", self.mid())); |
| 462 | } |
| 463 | |
| 464 | for m in &self.pts { |
| 465 | let rtp_count = self |
| 466 | .attrs |
| 467 | .iter() |
| 468 | .filter(|a| { |
| 469 | if let MediaAttribute::RtpMap { pt, value: _ } = a { |
| 470 | pt == m |
| 471 | } else { |
| 472 | false |
| 473 | } |
| 474 | }) |
| 475 | .count(); |
| 476 | if rtp_count == 0 && CodecSpec::from_static_pt(*m).is_none() { |
| 477 | return Some(format!("Missing a=rtp_map:{} for mid: {}", m, self.mid())); |
| 478 | } |
| 479 | if rtp_count > 1 { |
| 480 | return Some(format!( |
| 481 | "More than one a=rtp_map:{} for mid: {}", |
| 482 | m, |
| 483 | self.mid() |
| 484 | )); |
| 485 | } |
| 486 | } |
| 487 | None |
| 488 | } |
| 489 | |
| 490 | pub fn setup(&self) -> Option<Setup> { |
| 491 | let setup = self.attrs.iter().find_map(|m| { |
| 492 | if let MediaAttribute::Setup(v) = m { |
| 493 | Some(v) |
| 494 | } else { |
| 495 | None |
| 496 | } |
| 497 | })?; |
| 498 | |
| 499 | Some(*setup) |
| 500 | } |
| 501 | |
| 502 | pub fn ice_creds(&self) -> Option<IceCreds> { |
| 503 | let ufrag = self.attrs.iter().find_map(|m| { |
| 504 | if let MediaAttribute::IceUfrag(v) = m { |
| 505 | Some(v) |
| 506 | } else { |
| 507 | None |
| 508 | } |
| 509 | })?; |
| 510 | |
| 511 | let pass = self.attrs.iter().find_map(|m| { |
| 512 | if let MediaAttribute::IcePwd(v) = m { |
| 513 | Some(v) |
| 514 | } else { |
| 515 | None |
| 516 | } |
| 517 | })?; |
| 518 | |
| 519 | Some(IceCreds { |
| 520 | ufrag: ufrag.to_string(), |
| 521 | pass: pass.to_string(), |
| 522 | }) |
| 523 | } |
| 524 | pub fn fingerprint(&self) -> Option<Fingerprint> { |
| 525 | for a in &self.attrs { |
| 526 | if let MediaAttribute::Fingerprint(v) = a { |
| 527 | return Some(v.clone()); |
| 528 | } |
| 529 | } |
| 530 | None |
| 531 | } |
| 532 | |
| 533 | /// This hoovers the ice candidates from all m-lines, lots of dupes. |
| 534 | /// For WebRTC we don't expect different ice states per media line. |
| 535 | pub fn ice_candidates(&self) -> impl Iterator<Item = &Candidate> { |
| 536 | self.attrs.iter().filter_map(|a| { |
| 537 | if let MediaAttribute::Candidate(v) = a { |
| 538 | Some(v) |
| 539 | } else { |
| 540 | None |
| 541 | } |
| 542 | }) |
| 543 | } |
| 544 | |
| 545 | /// Any end-of-candidate in any m-line. |
| 546 | /// For WebRTC we don't expect different ice states per media line. |
| 547 | pub fn end_of_candidates(&self) -> bool { |
| 548 | self.attrs |
| 549 | .iter() |
| 550 | .any(|a| matches!(a, MediaAttribute::EndOfCandidates)) |
| 551 | } |
| 552 | |
| 553 | pub fn extmaps(&self) -> Vec<(u8, &Extension)> { |
| 554 | let mut ret = vec![]; |
| 555 | |
| 556 | for a in &self.attrs { |
| 557 | if let MediaAttribute::ExtMap { id, ext } = a { |
| 558 | ret.push((*id, ext)); |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | ret |
| 563 | } |
| 564 | |
| 565 | pub fn rids(&self) -> Vec<Rid> { |
| 566 | let mut ret = vec![]; |
| 567 | for a in &self.attrs { |
| 568 | if let MediaAttribute::Rid { id, .. } = a { |
| 569 | ret.push(id.0.as_str().into()) |
| 570 | } |
| 571 | } |
| 572 | ret |
| 573 | } |
| 574 | |
| 575 | pub fn simulcast(&self) -> Option<Simulcast> { |
| 576 | let mut found = None; |
| 577 | |
| 578 | for a in &self.attrs { |
| 579 | if let MediaAttribute::Simulcast(s) = a { |
| 580 | found = Some(s.clone()); |
| 581 | } |
| 582 | if let MediaAttribute::Rid { |
| 583 | pt, restriction, .. |
| 584 | } = a |
| 585 | { |
| 586 | if !pt.is_empty() { |
| 587 | warn!("Not currently supporting PT via a=rid"); |
| 588 | } |
| 589 | if !restriction.is_empty() { |
| 590 | warn!("Not currently supporting restrictions via a=rid"); |
| 591 | } |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | if found.is_some() { |
| 596 | return found; |
| 597 | } |
| 598 | |
| 599 | // Here we could handle munged SDPs and we used to, but we have dropped support for this. |
| 600 | |
| 601 | None |
| 602 | } |
| 603 | |
| 604 | pub fn ssrc_info(&self) -> Vec<SsrcInfo> { |
| 605 | let mut v = vec![]; |
| 606 | |
| 607 | fn by_ssrc(v: &mut Vec<SsrcInfo>, ssrc: Ssrc) -> &mut SsrcInfo { |
| 608 | if let Some(pos) = v.iter().position(|i| i.ssrc == ssrc) { |
| 609 | &mut v[pos] |
| 610 | } else { |
| 611 | v.push(SsrcInfo { |
| 612 | ssrc, |
| 613 | ..Default::default() |
| 614 | }); |
| 615 | v.last_mut().unwrap() |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | for a in &self.attrs { |
| 620 | match a { |
| 621 | MediaAttribute::Ssrc { ssrc, attr, value } => { |
| 622 | let info = by_ssrc(&mut v, *ssrc); |
| 623 | |
| 624 | // a=ssrc:2147603131 cname:TbS1Ajv9obq6/63I |
| 625 | // a=ssrc:2147603131 msid:- 7a08dda6-518f-4027-b707-410a6d414176 |
| 626 | match attr.to_lowercase().as_str() { |
| 627 | "cname" => info.cname = Some(value.clone()), |
| 628 | "msid" => { |
| 629 | let mut iter = value.split(' '); |
| 630 | |
| 631 | fn trim_and_no_minus(s: &str) -> Option<String> { |
| 632 | let s = s.trim(); |
| 633 | |
| 634 | if s == "-" { None } else { Some(s.into()) } |
| 635 | } |
| 636 | |
| 637 | if let Some(stream_id) = iter.next() { |
| 638 | info.stream_id = trim_and_no_minus(stream_id); |
| 639 | } |
| 640 | if let Some(track_id) = iter.next() { |
| 641 | info.track_id = trim_and_no_minus(track_id); |
| 642 | } |
| 643 | } |
| 644 | _ => {} |
| 645 | } |
| 646 | } |
| 647 | _ => {} |
| 648 | } |
| 649 | } |
| 650 | // Match this second to ensure we preserve order of a=ssrc. |
| 651 | for a in &self.attrs { |
| 652 | match a { |
| 653 | MediaAttribute::SsrcGroup { semantics, ssrcs } => { |
| 654 | if semantics.to_lowercase() != "fid" { |
| 655 | continue; |
| 656 | } |
| 657 | |
| 658 | // a=ssrc-group:FID 659652645 98148385 |
| 659 | // Should be two SSRC after FID. |
| 660 | if ssrcs.len() != 2 { |
| 661 | continue; |
| 662 | } |
| 663 | |
| 664 | let info = by_ssrc(&mut v, ssrcs[1]); |
| 665 | info.repairs = Some(ssrcs[0]); |
| 666 | } |
| 667 | _ => {} |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | v |
| 672 | } |
| 673 | |
| 674 | /// Get the max-message-size attribute value, if present |
| 675 | pub fn max_message_size(&self) -> Option<usize> { |
| 676 | self.attrs.iter().find_map(|a| { |
| 677 | if let MediaAttribute::MaxMessageSize(size) = a { |
| 678 | Some(*size) |
| 679 | } else { |
| 680 | None |
| 681 | } |
| 682 | }) |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 687 | pub struct SsrcInfo { |
| 688 | pub ssrc: Ssrc, |
| 689 | /// the other ssrc this ssrc is repairing |
| 690 | pub repairs: Option<Ssrc>, |
| 691 | pub cname: Option<String>, |
| 692 | pub stream_id: Option<String>, |
| 693 | pub track_id: Option<String>, |
| 694 | } |
| 695 | |
| 696 | impl Default for SsrcInfo { |
| 697 | fn default() -> Self { |
| 698 | Self { |
| 699 | ssrc: 0.into(), |
| 700 | repairs: None, |
| 701 | cname: None, |
| 702 | stream_id: None, |
| 703 | track_id: None, |
| 704 | } |
| 705 | } |
| 706 | } |
| 707 | |
| 708 | impl From<Direction> for MediaAttribute { |
| 709 | fn from(v: Direction) -> Self { |
| 710 | match v { |
| 711 | Direction::SendOnly => MediaAttribute::SendOnly, |
| 712 | Direction::RecvOnly => MediaAttribute::RecvOnly, |
| 713 | Direction::SendRecv => MediaAttribute::SendRecv, |
| 714 | Direction::Inactive => MediaAttribute::Inactive, |
| 715 | } |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | /// Identifier of an `a=rid` restriction. |
| 720 | /// |
| 721 | /// Defined in https://tools.ietf.org/html/draft-ietf-avtext-rid-09 |
| 722 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 723 | pub struct RestrictionId(pub String, pub bool); |
| 724 | |
| 725 | impl RestrictionId { |
| 726 | pub fn new(rid: String, active: bool) -> Self { |
| 727 | RestrictionId(rid, active) |
| 728 | } |
| 729 | |
| 730 | pub fn new_active(rid: String) -> Self { |
| 731 | RestrictionId(rid, true) |
| 732 | } |
| 733 | |
| 734 | pub fn to_sdp(&self) -> String { |
| 735 | format!("{}{}", if self.1 { "" } else { "~" }, self.0) |
| 736 | } |
| 737 | } |
| 738 | |
| 739 | impl Default for RestrictionId { |
| 740 | fn default() -> Self { |
| 741 | RestrictionId("".to_string(), true) |
| 742 | } |
| 743 | } |
| 744 | |
| 745 | /// "audio", "video", "application" |
| 746 | #[derive(Debug, Default, Clone, PartialEq, Eq)] |
| 747 | pub enum MediaType { |
| 748 | #[default] |
| 749 | Audio, |
| 750 | Video, |
| 751 | Application, |
| 752 | // If a parsed SDP has a value we don't recognize, we stick it |
| 753 | // in here. We could consider making that a parse exception instead. |
| 754 | #[doc(hidden)] |
| 755 | Unknown(String), |
| 756 | } |
| 757 | |
| 758 | impl MediaType { |
| 759 | pub fn is_media(&self) -> bool { |
| 760 | matches!(self, MediaType::Audio | MediaType::Video) |
| 761 | } |
| 762 | |
| 763 | pub fn is_channel(&self) -> bool { |
| 764 | matches!(self, MediaType::Application) |
| 765 | } |
| 766 | } |
| 767 | |
| 768 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 769 | pub enum Proto { |
| 770 | #[default] |
| 771 | Srtp, |
| 772 | Sctp, |
| 773 | } |
| 774 | |
| 775 | impl Proto { |
| 776 | pub fn proto_line(&self) -> &str { |
| 777 | match self { |
| 778 | Proto::Srtp => "UDP/TLS/RTP/SAVPF", |
| 779 | Proto::Sctp => "UDP/DTLS/SCTP", |
| 780 | } |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] |
| 785 | pub enum Setup { |
| 786 | #[default] |
| 787 | ActPass, |
| 788 | Active, |
| 789 | Passive, |
| 790 | } |
| 791 | |
| 792 | impl fmt::Display for Setup { |
| 793 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 794 | write!( |
| 795 | f, |
| 796 | "{}", |
| 797 | match self { |
| 798 | Setup::ActPass => "actpass", |
| 799 | Setup::Active => "active", |
| 800 | Setup::Passive => "passive", |
| 801 | } |
| 802 | ) |
| 803 | } |
| 804 | } |
| 805 | |
| 806 | impl Setup { |
| 807 | pub fn setup_line(&self) -> &str { |
| 808 | match self { |
| 809 | Setup::ActPass => "actpass", |
| 810 | Setup::Active => "active", |
| 811 | Setup::Passive => "passive", |
| 812 | } |
| 813 | } |
| 814 | |
| 815 | pub fn compare_to_remote(&self, remote: Setup) -> Option<Setup> { |
| 816 | use Setup::*; |
| 817 | match (self, remote) { |
| 818 | (ActPass, ActPass) => None, |
| 819 | (ActPass, Active) => Some(Passive), |
| 820 | (ActPass, Passive) => Some(Active), |
| 821 | (Active, ActPass) => Some(Active), |
| 822 | (Active, Active) => None, |
| 823 | (Active, Passive) => Some(Active), |
| 824 | (Passive, ActPass) => Some(Passive), |
| 825 | (Passive, Active) => Some(Passive), |
| 826 | (Passive, Passive) => None, |
| 827 | } |
| 828 | } |
| 829 | |
| 830 | pub fn invert(&self) -> Setup { |
| 831 | match self { |
| 832 | Setup::ActPass => Setup::ActPass, |
| 833 | Setup::Active => Setup::Passive, |
| 834 | Setup::Passive => Setup::Active, |
| 835 | } |
| 836 | } |
| 837 | } |
| 838 | |
| 839 | /// Attributes before the first m= line. |
| 840 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 841 | pub enum MediaAttribute { |
| 842 | // The "a=rtcp" line MUST NOT be added if the most recent answer included an "a=rtcp-mux" line. |
| 843 | Rtcp(String), |
| 844 | IceUfrag(String), |
| 845 | IcePwd(String), |
| 846 | IceOptions(String), |
| 847 | Fingerprint(Fingerprint), |
| 848 | Setup(Setup), // active, passive, actpass, holdconn |
| 849 | Mid(Mid), // 0, 1, 2 |
| 850 | SctpPort(u16), |
| 851 | MaxMessageSize(usize), |
| 852 | /// a=sctp-init:<base64-encoded SCTP INIT chunk> |
| 853 | /// See draft-hancke-tsvwg-snap |
| 854 | SctpInit(String), |
| 855 | // a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level |
| 856 | // a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time |
| 857 | ExtMap { |
| 858 | id: u8, // 1-14 inclusive, |
| 859 | ext: Extension, |
| 860 | }, |
| 861 | RecvOnly, // a=recvonly |
| 862 | SendRecv, // a=sendrecv |
| 863 | SendOnly, // a=sendonly |
| 864 | Inactive, // a=inactive |
| 865 | // a=msid:5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK f78dde68-7055-4e20-bb37-433803dd1ed1 |
| 866 | // a=msid:- 78dde68-7055-4e20-bb37-433803dd1ed1 |
| 867 | Msid(Msid), |
| 868 | RtcpMux, // |
| 869 | RtcpMuxOnly, // only in offer, answer with a=rtcp-mux |
| 870 | // reduced size rtcp. remove this if not supported. |
| 871 | RtcpRsize, |
| 872 | Candidate(Candidate), |
| 873 | EndOfCandidates, |
| 874 | RtpMap { |
| 875 | pt: Pt, |
| 876 | value: RtpMap, |
| 877 | }, |
| 878 | // rtcp-fb RTCP feedback parameters, repeated |
| 879 | RtcpFb { |
| 880 | pt: Pt, // 111 |
| 881 | value: String, // nack, nack pli, ccm fir... |
| 882 | }, |
| 883 | // format parameters, seems to be one of these |
| 884 | Fmtp { |
| 885 | pt: Pt, // 111 |
| 886 | values: Vec<FormatParam>, // minptime=10;useinbandfec=1 |
| 887 | }, |
| 888 | // a=rid:<rid-id> <direction> [pt=<fmt-list>;]<restriction>=<value> |
| 889 | // a=rid:hi send pt=111,112;max-br=64000;max-height=360 |
| 890 | // https://tools.ietf.org/html/draft-ietf-mmusic-rid-15 |
| 891 | Rid { |
| 892 | id: RestrictionId, // |
| 893 | direction: &'static str, // send or recv |
| 894 | // No pt means the rid applies to all |
| 895 | pt: Vec<Pt>, // 111, 112 (rtpmap no) |
| 896 | restriction: Vec<(String, String)>, |
| 897 | }, |
| 898 | // a=rid:hi send |
| 899 | // a=rid:lo send |
| 900 | // a=simulcast:send hi;lo |
| 901 | // https://tools.ietf.org/html/draft-ietf-mmusic-sdp-simulcast-14 |
| 902 | // a=simulcast:<send/recv> <alt A>;<alt B>,<or C> <send/recv> [same] |
| 903 | Simulcast(Simulcast), |
| 904 | SsrcGroup { |
| 905 | semantics: String, // i.e. "FID" |
| 906 | ssrcs: Vec<Ssrc>, // <normal stream> <repair stream> |
| 907 | }, |
| 908 | Ssrc { |
| 909 | ssrc: Ssrc, // synchronization source id |
| 910 | attr: String, |
| 911 | value: String, |
| 912 | }, |
| 913 | Unused(String), |
| 914 | } |
| 915 | |
| 916 | impl MediaAttribute { |
| 917 | pub fn is_direction(&self) -> bool { |
| 918 | use MediaAttribute::*; |
| 919 | matches!(self, RecvOnly | SendRecv | SendOnly | Inactive) |
| 920 | } |
| 921 | } |
| 922 | |
| 923 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 924 | pub enum FormatParam { |
| 925 | /// The minimum duration of media represented by a packet. |
| 926 | /// |
| 927 | /// Default 3. Max 120. |
| 928 | MinPTime(u8), |
| 929 | |
| 930 | /// Decoder-side preference for stereo audio. Default is mono per |
| 931 | /// RFC 7587 Section 7.1: <https://datatracker.ietf.org/doc/html/rfc7587#section-7.1> |
| 932 | Stereo(bool), |
| 933 | |
| 934 | /// Sender-side stereo hint for Opus. This is the symmetric m-line companion |
| 935 | /// to `stereo` and signals the sender's intention to transmit stereo. |
| 936 | SpropStereo(bool), |
| 937 | |
| 938 | /// Specifies that the decoder can do Opus in-band FEC |
| 939 | UseInbandFec(bool), |
| 940 | |
| 941 | /// Specifies that the decoder can do Opus DTX |
| 942 | UseDtx(bool), |
| 943 | |
| 944 | /// Whether h264 sending media encoded at a different level in the offerer-to-answerer |
| 945 | /// direction than the level in the answerer-to-offerer direction, is allowed. |
| 946 | LevelAsymmetryAllowed(bool), |
| 947 | |
| 948 | /// What h264 packetization mode is used. |
| 949 | /// |
| 950 | /// * 0 - single nal. |
| 951 | /// * 1 - STAP-A, FU-A is allowed. Non-interleaved. |
| 952 | PacketizationMode(u8), |
| 953 | |
| 954 | /// H264 profile level. |
| 955 | /// |
| 956 | /// * 42 00 1f - 4200=baseline (B) 1f=level 3.1 |
| 957 | /// * 42 e0 1f - 42e0=constrained baseline (CB) 1f=level 3.1 |
| 958 | /// * 4d 00 1f - 4d00=main (M) 1f=level 3.1 |
| 959 | /// * 64 00 1f - 6400=high (H) 1f=level 3.1 |
| 960 | ProfileLevelId(u32), |
| 961 | |
| 962 | /// VP9 profile id |
| 963 | ProfileId(u32), |
| 964 | |
| 965 | /// AV1 profile |
| 966 | Profile(u8), |
| 967 | |
| 968 | /// AV1 level-idx |
| 969 | LevelIdx(u8), |
| 970 | |
| 971 | /// AV1 tier |
| 972 | Tier(u8), |
| 973 | |
| 974 | /// H.265/HEVC profile, tier, and level. |
| 975 | H265ProfileTierLevel(crate::packet::H265ProfileTierLevel), |
| 976 | |
| 977 | /// H.266/VVC profile, tier, and level. |
| 978 | H266ProfileTierLevel(crate::packet::H266ProfileTierLevel), |
| 979 | |
| 980 | /// H.265 sprop-max-don-diff parameter (RFC 7798 ยง7.1). |
| 981 | /// When > 0, DONL fields are included in RTP packets to support |
| 982 | /// out-of-order NAL unit decoding. Valid range: 0โ32767. |
| 983 | SpropMaxDonDiff(u16), |
| 984 | |
| 985 | /// RTX (resend) codecs, which PT it concerns. |
| 986 | Apt(Pt), |
| 987 | |
| 988 | /// Unrecognized fmtp. |
| 989 | Unknown, |
| 990 | } |
| 991 | |
| 992 | impl FormatParam { |
| 993 | pub fn parse(k: &str, v: &str) -> Self { |
| 994 | use FormatParam::*; |
| 995 | match k { |
| 996 | "minptime" => v.parse().map(MinPTime).ok(), |
| 997 | "useinbandfec" => Some(UseInbandFec(v == "1")), |
| 998 | "usedtx" => Some(UseDtx(v == "1")), |
| 999 | "stereo" => Some(Stereo(v == "1")), |
| 1000 | "sprop-stereo" => Some(SpropStereo(v == "1")), |
| 1001 | "level-asymmetry-allowed" => Some(LevelAsymmetryAllowed(v == "1")), |
| 1002 | "packetization-mode" => v.parse().map(PacketizationMode).ok(), |
| 1003 | "profile-level-id" => u32::from_str_radix(v, 16) |
| 1004 | .or_else(|_| v.parse()) |
| 1005 | .map(ProfileLevelId) |
| 1006 | .ok(), |
| 1007 | "profile-id" => v.parse().map(ProfileId).ok(), |
| 1008 | "profile" => v.parse().map(Profile).ok(), |
| 1009 | "level-idx" => v.parse().map(LevelIdx).ok(), |
| 1010 | "tier" => v.parse().map(Tier).ok(), |
| 1011 | "sprop-max-don-diff" => v |
| 1012 | .parse::<u16>() |
| 1013 | .ok() |
| 1014 | .filter(|&v| v <= 32767) |
| 1015 | .map(SpropMaxDonDiff), |
| 1016 | "apt" => v.parse::<u8>().map(|v| Apt(Pt::from(v))).ok(), |
| 1017 | _ => None, |
| 1018 | } |
| 1019 | .unwrap_or_else(|| { |
| 1020 | trace!("Failed to parse FormatParam: {k}={v}"); |
| 1021 | Unknown |
| 1022 | }) |
| 1023 | } |
| 1024 | |
| 1025 | /// Parse multiple format parameters from key-value pairs. |
| 1026 | /// Handles the H.265/H.266 special case where three params combine into |
| 1027 | /// one composite ProfileTierLevel. |
| 1028 | pub fn parse_pairs(pairs: Vec<(String, String)>) -> Vec<FormatParam> { |
| 1029 | // Check if this looks like H.265/H.266 by presence of tier-flag or level-id. |
| 1030 | // Both codecs use the same three fmtp keys (RFC 7798 / RFC 9328); |
| 1031 | // their level-id value spaces are disjoint (H.265: 30รlevel, e.g. 93; |
| 1032 | // H.266: 16รmajor + 3รminor, e.g. 51), which disambiguates them. |
| 1033 | let is_ptl = pairs |
| 1034 | .iter() |
| 1035 | .any(|(k, _)| k == "tier-flag" || k == "level-id"); |
| 1036 | |
| 1037 | if is_ptl { |
| 1038 | // Build composite ProfileTierLevel: H.265 first, else H.266. |
| 1039 | let map: HashMap<String, String> = pairs.into_iter().collect(); |
| 1040 | |
| 1041 | let mut result = vec![]; |
| 1042 | if let Some(ptl) = H265ProfileTierLevel::from_fmtp(&map) { |
| 1043 | result.push(FormatParam::H265ProfileTierLevel(ptl)); |
| 1044 | } else if let Some(ptl) = H266ProfileTierLevel::from_fmtp(&map) { |
| 1045 | result.push(FormatParam::H266ProfileTierLevel(ptl)); |
| 1046 | } |
| 1047 | |
| 1048 | // Include non-PTL parameters. |
| 1049 | for (k, v) in map.iter() { |
| 1050 | if k != "profile-id" && k != "tier-flag" && k != "level-id" { |
| 1051 | result.push(FormatParam::parse(k, v)); |
| 1052 | } |
| 1053 | } |
| 1054 | |
| 1055 | result |
| 1056 | } else { |
| 1057 | // Standard parsing for non-PTL codecs. |
| 1058 | pairs |
| 1059 | .into_iter() |
| 1060 | .map(|(k, v)| FormatParam::parse(&k, &v)) |
| 1061 | .collect() |
| 1062 | } |
| 1063 | } |
| 1064 | } |
| 1065 | |
| 1066 | impl fmt::Display for FormatParam { |
| 1067 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1068 | use FormatParam::*; |
| 1069 | match self { |
| 1070 | MinPTime(v) => write!(f, "minptime={v}"), |
| 1071 | UseInbandFec(v) => write!(f, "useinbandfec={}", i32::from(*v)), |
| 1072 | UseDtx(v) => write!(f, "usedtx={}", i32::from(*v)), |
| 1073 | Stereo(v) => write!(f, "stereo={}", i32::from(*v)), |
| 1074 | SpropStereo(v) => write!(f, "sprop-stereo={}", i32::from(*v)), |
| 1075 | LevelAsymmetryAllowed(v) => { |
| 1076 | write!(f, "level-asymmetry-allowed={}", i32::from(*v)) |
| 1077 | } |
| 1078 | PacketizationMode(v) => write!(f, "packetization-mode={}", *v), |
| 1079 | ProfileLevelId(v) => write!(f, "profile-level-id={:06x}", *v), |
| 1080 | ProfileId(v) => write!(f, "profile-id={}", *v), |
| 1081 | Profile(v) => write!(f, "profile={}", v), |
| 1082 | LevelIdx(v) => write!(f, "level-idx={}", *v), |
| 1083 | Tier(v) => write!(f, "tier={}", v), |
| 1084 | H265ProfileTierLevel(ptl) => { |
| 1085 | write!( |
| 1086 | f, |
| 1087 | "profile-id={};tier-flag={};level-id={}", |
| 1088 | ptl.profile_id(), |
| 1089 | ptl.tier_flag(), |
| 1090 | ptl.level_id() |
| 1091 | ) |
| 1092 | } |
| 1093 | H266ProfileTierLevel(ptl) => { |
| 1094 | write!( |
| 1095 | f, |
| 1096 | "profile-id={};tier-flag={};level-id={}", |
| 1097 | ptl.profile_id(), |
| 1098 | ptl.tier_flag(), |
| 1099 | ptl.level_id() |
| 1100 | ) |
| 1101 | } |
| 1102 | SpropMaxDonDiff(v) => write!(f, "sprop-max-don-diff={}", *v), |
| 1103 | Apt(v) => write!(f, "apt={v}"), |
| 1104 | Unknown => Ok(()), |
| 1105 | } |
| 1106 | } |
| 1107 | } |
| 1108 | |
| 1109 | impl PayloadParams { |
| 1110 | pub(crate) fn as_media_attrs(&self, attrs: &mut Vec<MediaAttribute>) { |
| 1111 | attrs.push(MediaAttribute::RtpMap { |
| 1112 | pt: self.pt, |
| 1113 | value: self.spec.into(), |
| 1114 | }); |
| 1115 | |
| 1116 | if self.fb_transport_cc { |
| 1117 | attrs.push(MediaAttribute::RtcpFb { |
| 1118 | pt: self.pt, |
| 1119 | value: "transport-cc".into(), |
| 1120 | }); |
| 1121 | } |
| 1122 | if self.fb_remb { |
| 1123 | attrs.push(MediaAttribute::RtcpFb { |
| 1124 | pt: self.pt, |
| 1125 | value: "goog-remb".into(), |
| 1126 | }); |
| 1127 | } |
| 1128 | if self.fb_fir { |
| 1129 | attrs.push(MediaAttribute::RtcpFb { |
| 1130 | pt: self.pt, |
| 1131 | value: "ccm fir".into(), |
| 1132 | }); |
| 1133 | } |
| 1134 | if self.fb_nack { |
| 1135 | attrs.push(MediaAttribute::RtcpFb { |
| 1136 | pt: self.pt, |
| 1137 | value: "nack".into(), |
| 1138 | }); |
| 1139 | } |
| 1140 | if self.fb_pli { |
| 1141 | attrs.push(MediaAttribute::RtcpFb { |
| 1142 | pt: self.pt, |
| 1143 | value: "nack pli".into(), |
| 1144 | }); |
| 1145 | } |
| 1146 | |
| 1147 | let fmtps = self.spec.format.to_format_param(); |
| 1148 | if !fmtps.is_empty() { |
| 1149 | attrs.push(MediaAttribute::Fmtp { |
| 1150 | pt: self.pt, |
| 1151 | values: fmtps, |
| 1152 | }); |
| 1153 | } |
| 1154 | |
| 1155 | if let Some(pt) = self.resend { |
| 1156 | attrs.push(MediaAttribute::RtpMap { |
| 1157 | pt, |
| 1158 | value: RtpMap { |
| 1159 | codec: Codec::Rtx, |
| 1160 | clock_rate: self.spec.rtp_clock_rate(), |
| 1161 | channels: None, |
| 1162 | }, |
| 1163 | }); |
| 1164 | attrs.push(MediaAttribute::Fmtp { |
| 1165 | pt, |
| 1166 | values: vec![FormatParam::Apt(self.pt)], |
| 1167 | }); |
| 1168 | } |
| 1169 | } |
| 1170 | } |
| 1171 | |
| 1172 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1173 | pub struct RtpMap { |
| 1174 | pub codec: Codec, |
| 1175 | pub clock_rate: Frequency, |
| 1176 | pub channels: Option<u8>, |
| 1177 | } |
| 1178 | |
| 1179 | impl From<RtpMap> for CodecSpec { |
| 1180 | fn from(v: RtpMap) -> Self { |
| 1181 | // G722 uses an 8000 Hz RTP clock rate in SDP (RFC 3551 ยง4.5.2), but str0m |
| 1182 | // treats it as a 16 kHz mono codec everywhere user facing. Normalize on the way in. |
| 1183 | // See https://en.wikipedia.org/wiki/RTP_payload_formats#cite_note-55 |
| 1184 | let (clock_rate, channels) = if v.codec == Codec::G722 { |
| 1185 | let channels = if v.channels == Some(1) { |
| 1186 | None |
| 1187 | } else { |
| 1188 | v.channels |
| 1189 | }; |
| 1190 | (Frequency::SIXTEEN_KHZ, channels) |
| 1191 | } else { |
| 1192 | (v.clock_rate, v.channels) |
| 1193 | }; |
| 1194 | CodecSpec { |
| 1195 | codec: v.codec, |
| 1196 | clock_rate, |
| 1197 | channels, |
| 1198 | format: FormatParams::default(), |
| 1199 | } |
| 1200 | } |
| 1201 | } |
| 1202 | |
| 1203 | impl From<CodecSpec> for RtpMap { |
| 1204 | fn from(v: CodecSpec) -> Self { |
| 1205 | RtpMap { |
| 1206 | codec: v.codec, |
| 1207 | // G722 is signalled with an 8000 Hz clock rate in SDP (RFC 3551 ยง4.5.2), |
| 1208 | // even though it samples at 16 kHz. See |
| 1209 | // https://en.wikipedia.org/wiki/RTP_payload_formats#cite_note-55 |
| 1210 | clock_rate: v.rtp_clock_rate(), |
| 1211 | channels: v.channels, |
| 1212 | } |
| 1213 | } |
| 1214 | } |
| 1215 | |
| 1216 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 1217 | pub struct Simulcast { |
| 1218 | pub send: SimulcastGroups, |
| 1219 | pub recv: SimulcastGroups, |
| 1220 | /// If this is created synthetically for a munged SDP. |
| 1221 | pub is_munged: bool, |
| 1222 | } |
| 1223 | |
| 1224 | impl Simulcast { |
| 1225 | pub fn invert(self) -> Self { |
| 1226 | Simulcast { |
| 1227 | send: self.recv, |
| 1228 | recv: self.send, |
| 1229 | is_munged: self.is_munged, |
| 1230 | } |
| 1231 | } |
| 1232 | } |
| 1233 | |
| 1234 | /// RID organization inside a=simulcast line. |
| 1235 | /// |
| 1236 | /// `a=simulcast send 2;3,4` would result in |
| 1237 | /// `SimulcastGroups(vec![SimulcastLayer("2"), SimulcastLayer("3")])` |
| 1238 | /// |
| 1239 | /// The choice between 3 and 4 for the rid is currently ignored and the first option is always |
| 1240 | /// selected. |
| 1241 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 1242 | pub struct SimulcastGroups(pub Vec<SimulcastLayer>); |
| 1243 | |
| 1244 | /// Represents a simulcast layer in a group |
| 1245 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 1246 | pub struct SimulcastLayer { |
| 1247 | /// The layer's rid |
| 1248 | pub restriction_id: RestrictionId, |
| 1249 | /// Optional attributes per RFC 8851 |
| 1250 | pub attributes: Option<Vec<(String, String)>>, |
| 1251 | } |
| 1252 | |
| 1253 | impl Deref for SimulcastGroups { |
| 1254 | type Target = [SimulcastLayer]; |
| 1255 | |
| 1256 | fn deref(&self) -> &Self::Target { |
| 1257 | &self.0 |
| 1258 | } |
| 1259 | } |
| 1260 | |
| 1261 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1262 | pub struct Msid { |
| 1263 | pub stream_id: String, |
| 1264 | pub track_id: String, |
| 1265 | } |
| 1266 | |
| 1267 | impl Msid { |
| 1268 | pub fn random() -> Self { |
| 1269 | Msid { |
| 1270 | stream_id: Id::<30>::random().to_string(), |
| 1271 | track_id: Id::<30>::random().to_string(), |
| 1272 | } |
| 1273 | } |
| 1274 | } |
| 1275 | |
| 1276 | impl fmt::Display for SimulcastGroups { |
| 1277 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1278 | for (idx, layer) in self.0.iter().enumerate() { |
| 1279 | if idx + 1 == self.0.len() { |
| 1280 | write!(f, "{}", layer.restriction_id.to_sdp())?; |
| 1281 | } else { |
| 1282 | write!(f, "{};", layer.restriction_id.to_sdp())?; |
| 1283 | } |
| 1284 | } |
| 1285 | Ok(()) |
| 1286 | } |
| 1287 | } |
| 1288 | |
| 1289 | impl fmt::Display for Sdp { |
| 1290 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1291 | write!(f, "{}", self.session)?; |
| 1292 | for m in &self.media_lines { |
| 1293 | write!(f, "{m}")?; |
| 1294 | } |
| 1295 | Ok(()) |
| 1296 | } |
| 1297 | } |
| 1298 | |
| 1299 | impl fmt::Display for Session { |
| 1300 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1301 | write!(f, "v=0\r\n")?; |
| 1302 | write!(f, "o=str0m-{} {} 2 IN IP4 0.0.0.0\r\n", VERSION, self.id)?; |
| 1303 | write!(f, "s=-\r\n")?; |
| 1304 | if let Some(bw) = &self.bw { |
| 1305 | write!(f, "b={}:{}\r\n", bw.typ, bw.val)?; |
| 1306 | } |
| 1307 | write!(f, "t=0 0\r\n")?; |
| 1308 | for a in &self.attrs { |
| 1309 | write!(f, "{a}")?; |
| 1310 | } |
| 1311 | Ok(()) |
| 1312 | } |
| 1313 | } |
| 1314 | |
| 1315 | impl fmt::Display for SessionAttribute { |
| 1316 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1317 | use SessionAttribute::*; |
| 1318 | match self { |
| 1319 | Group { typ, mids } => { |
| 1320 | let mids: Vec<_> = mids.iter().map(|m| m.to_string()).collect(); |
| 1321 | write!(f, "a=group:{} {}\r\n", typ, mids.join(" "))?; |
| 1322 | } |
| 1323 | AllowMixedExts => write!(f, "a=extmap-allow-mixed\r\n")?, |
| 1324 | MsidSemantic { |
| 1325 | semantic, |
| 1326 | stream_ids, |
| 1327 | } => { |
| 1328 | write!( |
| 1329 | f, |
| 1330 | "a=msid-semantic: {} {}\r\n", |
| 1331 | semantic, |
| 1332 | stream_ids.join(" ") |
| 1333 | )?; |
| 1334 | } |
| 1335 | IceLite => write!(f, "a=ice-lite\r\n")?, |
| 1336 | IceUfrag(v) => write!(f, "a=ice-ufrag:{v}\r\n")?, |
| 1337 | IcePwd(v) => write!(f, "a=ice-pwd:{v}\r\n")?, |
| 1338 | IceOptions(v) => write!(f, "a=ice-options:{v}\r\n")?, |
| 1339 | Fingerprint(v) => { |
| 1340 | write!( |
| 1341 | f, |
| 1342 | "a=fingerprint:{} {}\r\n", |
| 1343 | v.hash_func, |
| 1344 | FingerprintFmt(&v.bytes) |
| 1345 | )?; |
| 1346 | } |
| 1347 | Setup(v) => write!(f, "a=setup:{}\r\n", v.setup_line())?, |
| 1348 | Candidate(c) => write!(f, "a={}\r\n", c.to_sdp_string())?, |
| 1349 | EndOfCandidates => write!(f, "a=end-of-candidates\r\n")?, |
| 1350 | Unused(v) => write!(f, "a={v}\r\n")?, |
| 1351 | } |
| 1352 | Ok(()) |
| 1353 | } |
| 1354 | } |
| 1355 | |
| 1356 | impl fmt::Display for MediaLine { |
| 1357 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1358 | let port = if self.disabled { 0 } else { 9 }; |
| 1359 | write!(f, "m={} {} {} ", self.typ, port, self.proto)?; |
| 1360 | let len = self.pts.len(); |
| 1361 | if self.typ.is_channel() { |
| 1362 | write!(f, "webrtc-datachannel\r\n")?; |
| 1363 | } else { |
| 1364 | for (idx, m) in self.pts.iter().enumerate() { |
| 1365 | if idx + 1 < len { |
| 1366 | write!(f, "{m} ")?; |
| 1367 | } else { |
| 1368 | write!(f, "{m}")?; |
| 1369 | } |
| 1370 | } |
| 1371 | write!(f, "\r\n")?; |
| 1372 | } |
| 1373 | write!(f, "c=IN IP4 0.0.0.0\r\n")?; |
| 1374 | if let Some(bw) = &self.bw { |
| 1375 | write!(f, "b={}:{}\r\n", bw.typ, bw.val)?; |
| 1376 | } |
| 1377 | for a in &self.attrs { |
| 1378 | write!(f, "{a}")?; |
| 1379 | } |
| 1380 | Ok(()) |
| 1381 | } |
| 1382 | } |
| 1383 | |
| 1384 | impl fmt::Display for MediaType { |
| 1385 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1386 | match self { |
| 1387 | MediaType::Audio => write!(f, "audio"), |
| 1388 | MediaType::Video => write!(f, "video"), |
| 1389 | MediaType::Application => write!(f, "application"), |
| 1390 | MediaType::Unknown(v) => write!(f, "{v}"), |
| 1391 | } |
| 1392 | } |
| 1393 | } |
| 1394 | |
| 1395 | impl fmt::Display for Proto { |
| 1396 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1397 | write!(f, "{}", self.proto_line()) |
| 1398 | } |
| 1399 | } |
| 1400 | |
| 1401 | impl fmt::Display for MediaAttribute { |
| 1402 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1403 | use MediaAttribute::*; |
| 1404 | match self { |
| 1405 | Rtcp(v) => write!(f, "a=rtcp:{v}\r\n")?, |
| 1406 | IceUfrag(v) => write!(f, "a=ice-ufrag:{v}\r\n")?, |
| 1407 | IcePwd(v) => write!(f, "a=ice-pwd:{v}\r\n")?, |
| 1408 | IceOptions(v) => write!(f, "a=ice-options:{v}\r\n")?, |
| 1409 | Fingerprint(v) => { |
| 1410 | write!( |
| 1411 | f, |
| 1412 | "a=fingerprint:{} {}\r\n", |
| 1413 | v.hash_func, |
| 1414 | FingerprintFmt(&v.bytes) |
| 1415 | )?; |
| 1416 | } |
| 1417 | Setup(v) => write!(f, "a=setup:{}\r\n", v.setup_line())?, |
| 1418 | Mid(v) => write!(f, "a=mid:{v}\r\n")?, |
| 1419 | SctpPort(v) => write!(f, "a=sctp-port:{v}\r\n")?, |
| 1420 | MaxMessageSize(v) => write!(f, "a=max-message-size:{v}\r\n")?, |
| 1421 | SctpInit(v) => write!(f, "a=sctp-init:{v}\r\n")?, |
| 1422 | ExtMap { id, ext } => { |
| 1423 | if !ext.is_serialized() { |
| 1424 | return Ok(()); |
| 1425 | } |
| 1426 | write!(f, "a=extmap:{}", id)?; |
| 1427 | // if let Some(d) = &e.direction { |
| 1428 | // write!(f, "/{d}")?; |
| 1429 | // } |
| 1430 | write!(f, " {}", ext.as_uri())?; |
| 1431 | // if let Some(e) = &e.ext { |
| 1432 | // write!(f, " {}", e)?; |
| 1433 | // } |
| 1434 | write!(f, "\r\n")?; |
| 1435 | } |
| 1436 | RecvOnly => write!(f, "a=recvonly\r\n")?, |
| 1437 | SendRecv => write!(f, "a=sendrecv\r\n")?, |
| 1438 | SendOnly => write!(f, "a=sendonly\r\n")?, |
| 1439 | Inactive => write!(f, "a=inactive\r\n")?, |
| 1440 | // a=msid:5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK f78dde68-7055-4e20-bb37-433803dd1ed1 |
| 1441 | // a=msid:- 78dde68-7055-4e20-bb37-433803dd1ed1 |
| 1442 | Msid(v) => write!(f, "a=msid:{} {}\r\n", v.stream_id, v.track_id)?, |
| 1443 | RtcpMux => write!(f, "a=rtcp-mux\r\n")?, |
| 1444 | RtcpMuxOnly => write!(f, "a=rtcp-mux-only\r\n")?, |
| 1445 | RtcpRsize => write!(f, "a=rtcp-rsize\r\n")?, |
| 1446 | Candidate(c) => write!(f, "a={}\r\n", c.to_sdp_string())?, |
| 1447 | EndOfCandidates => write!(f, "a=end-of-candidates\r\n")?, |
| 1448 | RtpMap { pt, value: c } => { |
| 1449 | write!(f, "a=rtpmap:{} {}/{}", pt, c.codec, c.clock_rate)?; |
| 1450 | if let Some(e) = c.channels { |
| 1451 | write!(f, "/{e}")?; |
| 1452 | } |
| 1453 | write!(f, "\r\n")?; |
| 1454 | } |
| 1455 | RtcpFb { pt, value } => write!(f, "a=rtcp-fb:{pt} {value}\r\n")?, |
| 1456 | Fmtp { pt, values } => { |
| 1457 | if values.is_empty() { |
| 1458 | // Skip empty fmtp lines - they're invalid SDP |
| 1459 | return Ok(()); |
| 1460 | } |
| 1461 | write!(f, "a=fmtp:{pt} ")?; |
| 1462 | for (idx, v) in values.iter().enumerate() { |
| 1463 | if idx + 1 < values.len() { |
| 1464 | write!(f, "{v};")?; |
| 1465 | } else { |
| 1466 | write!(f, "{v}\r\n")?; |
| 1467 | } |
| 1468 | } |
| 1469 | } |
| 1470 | // a=rid:hi send pt=111,112;max-br=64000;max-height=360 |
| 1471 | Rid { |
| 1472 | id, |
| 1473 | direction, |
| 1474 | pt, |
| 1475 | restriction, |
| 1476 | } => { |
| 1477 | write!(f, "a=rid:{} {}", id.0, direction)?; |
| 1478 | for (idx, p) in pt.iter().enumerate() { |
| 1479 | if idx == 0 { |
| 1480 | write!(f, " pt=")?; |
| 1481 | } |
| 1482 | if idx + 1 == pt.len() { |
| 1483 | write!(f, "{p}")?; |
| 1484 | } else { |
| 1485 | write!(f, "{p},")?; |
| 1486 | } |
| 1487 | } |
| 1488 | for (idx, (k, v)) in restriction.iter().enumerate() { |
| 1489 | if idx == 0 { |
| 1490 | if pt.is_empty() { |
| 1491 | write!(f, " ")?; |
| 1492 | } else { |
| 1493 | write!(f, ";")?; |
| 1494 | } |
| 1495 | } |
| 1496 | if idx + 1 == restriction.len() { |
| 1497 | write!(f, "{k}={v}")?; |
| 1498 | } else { |
| 1499 | write!(f, "{k}={v};")?; |
| 1500 | } |
| 1501 | } |
| 1502 | write!(f, "\r\n")?; |
| 1503 | } |
| 1504 | // a=simulcast:<send/recv> <alt A>;<alt B>,<or C> <send/recv> [same] |
| 1505 | Simulcast(x) => { |
| 1506 | let self::Simulcast { |
| 1507 | send, |
| 1508 | recv, |
| 1509 | is_munged, |
| 1510 | } = x; |
| 1511 | assert!( |
| 1512 | !(send.0.is_empty() && recv.0.is_empty()), |
| 1513 | "Empty a=simulcast" |
| 1514 | ); |
| 1515 | if *is_munged { |
| 1516 | // don't write |
| 1517 | return Ok(()); |
| 1518 | } |
| 1519 | write!(f, "a=simulcast:")?; |
| 1520 | if !send.0.is_empty() { |
| 1521 | write!(f, "send {send}")?; |
| 1522 | } |
| 1523 | if !recv.0.is_empty() { |
| 1524 | if !send.0.is_empty() { |
| 1525 | write!(f, " ")?; |
| 1526 | } |
| 1527 | write!(f, "recv {recv}")?; |
| 1528 | } |
| 1529 | write!(f, "\r\n")?; |
| 1530 | } |
| 1531 | SsrcGroup { semantics, ssrcs } => { |
| 1532 | write!(f, "a=ssrc-group:{semantics} ")?; |
| 1533 | for (idx, ssrc) in ssrcs.iter().enumerate() { |
| 1534 | if idx + 1 < ssrcs.len() { |
| 1535 | write!(f, "{} ", **ssrc)?; |
| 1536 | } else { |
| 1537 | write!(f, "{}\r\n", **ssrc)?; |
| 1538 | } |
| 1539 | } |
| 1540 | } |
| 1541 | Ssrc { ssrc, attr, value } => { |
| 1542 | write!(f, "a=ssrc:{} {}:{}\r\n", **ssrc, attr, value)?; |
| 1543 | } |
| 1544 | Unused(v) => write!(f, "a={v}\r\n")?, |
| 1545 | } |
| 1546 | Ok(()) |
| 1547 | } |
| 1548 | } |
| 1549 | |
| 1550 | pub struct FingerprintFmt<'a>(pub &'a [u8]); |
| 1551 | |
| 1552 | impl<'a> std::fmt::Display for FingerprintFmt<'a> { |
| 1553 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 1554 | let last = self.0.len() - 1; |
| 1555 | for (idx, b) in self.0.iter().enumerate() { |
| 1556 | if idx < last { |
| 1557 | write!(f, "{b:02X}:")?; |
| 1558 | } else { |
| 1559 | write!(f, "{b:02X}")?; |
| 1560 | } |
| 1561 | } |
| 1562 | Ok(()) |
| 1563 | } |
| 1564 | } |
| 1565 | |
| 1566 | #[cfg(test)] |
| 1567 | mod test { |
| 1568 | use crate::VERSION; |
| 1569 | use crate::packet::H265ProfileTierLevel; |
| 1570 | use crate::rtp_::{Extension, Frequency}; |
| 1571 | |
| 1572 | use super::*; |
| 1573 | |
| 1574 | /// Tests for general format parameter serialization and parsing. |
| 1575 | /// These tests verify that format parameters can be correctly converted to/from strings. |
| 1576 | mod format_params { |
| 1577 | use super::*; |
| 1578 | |
| 1579 | #[test] |
| 1580 | fn fmtp_param_to_string() { |
| 1581 | let f = FormatParams { |
| 1582 | min_p_time: Some(10), |
| 1583 | stereo: Some(true), |
| 1584 | use_inband_fec: Some(true), |
| 1585 | ..Default::default() |
| 1586 | }; |
| 1587 | assert_eq!(f.to_string(), "minptime=10;stereo=1;useinbandfec=1"); |
| 1588 | } |
| 1589 | } |
| 1590 | |
| 1591 | /// Tests for SDP parsing, structure validation, and serialization. |
| 1592 | /// Covers error handling, SDP generation, BUNDLE groups, and media line configuration. |
| 1593 | mod sdp_parsing { |
| 1594 | use super::*; |
| 1595 | |
| 1596 | #[test] |
| 1597 | fn parse_error() { |
| 1598 | let input = "v=0\r\n\ |
| 1599 | o=mozilla...THIS_IS_SDPARTA-99.0 7710052215259647220 2 IN IP4 0.0.0.0\r\n\ |
| 1600 | s=-\r\n\ |
| 1601 | t=0 0\r\n\ |
| 1602 | a=fingerprint:sha-256 A6:64:23:37:94:7E:4B:40:F6:62:86:8C:DD:09:D5:08:\ |
| 1603 | 7E:D4:0E:68:58:93:45:EC:99:F2:91:F7:19:72:E7:BB\r\n\ |
| 1604 | a=group:BUNDLE 0 hxI i1X mxk B3D kNI nbB xIZ bKm Hkn\r\n\ |
| 1605 | a=ice-options:trickle\r\n\ |
| 1606 | a=msid-semantic:WMS *\r\n\ |
| 1607 | m=audio 0 UDP/TLS/RTP/SAVPF 0\r\n\ |
| 1608 | c=IN IP4 0.0.0.0\r\n\ |
| 1609 | a=setup:actpass\r\n\ |
| 1610 | a=mid:1\r\n\ |
| 1611 | a=rtpmap:0 PCMU/8000\r\n\ |
| 1612 | "; |
| 1613 | |
| 1614 | let sdp = Sdp::parse(input); |
| 1615 | |
| 1616 | match sdp { |
| 1617 | Err(SdpError::ParseError(out)) => { |
| 1618 | assert!(out.starts_with(&"Parse error at ".to_string())); |
| 1619 | assert!(out.contains( |
| 1620 | &"Expected exactly one of a=sendrecv, a=sendonly, a=recvonly, a=inactive for mid: 1" |
| 1621 | .to_string() |
| 1622 | )); |
| 1623 | } |
| 1624 | _ => panic!(), |
| 1625 | } |
| 1626 | } |
| 1627 | |
| 1628 | #[test] |
| 1629 | fn write_sdp() { |
| 1630 | let sdp = Sdp { |
| 1631 | session: Session { |
| 1632 | id: 5_058_682_828_002_148_772.into(), |
| 1633 | bw: None, |
| 1634 | attrs: vec![ |
| 1635 | SessionAttribute::Group { |
| 1636 | typ: "BUNDLE".into(), |
| 1637 | mids: vec!["0".into()], |
| 1638 | }, |
| 1639 | SessionAttribute::Unused( |
| 1640 | "msid-semantic: WMS 5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK".into(), |
| 1641 | ), |
| 1642 | ], |
| 1643 | }, |
| 1644 | media_lines: vec![ |
| 1645 | MediaLine { |
| 1646 | typ: MediaType::Audio, |
| 1647 | disabled: false, |
| 1648 | proto: Proto::Srtp, |
| 1649 | pts: vec![111, 103, 104, 9, 0, 8, 106, 105, 13, 110, 112, 113, 126] |
| 1650 | .into_iter() |
| 1651 | .map(Pt::from) |
| 1652 | .collect(), |
| 1653 | bw: None, |
| 1654 | attrs: vec![ |
| 1655 | MediaAttribute::Rtcp("9 IN IP4 0.0.0.0".into()), |
| 1656 | MediaAttribute::IceUfrag("S5hk".into()), |
| 1657 | MediaAttribute::IcePwd("0zV/Yu3y8aDzbHgqWhnVQhqP".into()), |
| 1658 | MediaAttribute::IceOptions("trickle".into()), |
| 1659 | MediaAttribute::Fingerprint(Fingerprint { |
| 1660 | hash_func: "sha-256".into(), |
| 1661 | bytes: vec![ |
| 1662 | 140, 100, 237, 3, 118, 208, 61, 180, 136, 8, 145, 100, 8, 128, |
| 1663 | 168, 198, 90, 191, 139, 78, 56, 39, 150, 202, 8, 73, 37, 115, |
| 1664 | 70, 96, 32, 220, |
| 1665 | ], |
| 1666 | }), |
| 1667 | MediaAttribute::Setup(Setup::ActPass), |
| 1668 | MediaAttribute::Mid("0".into()), |
| 1669 | MediaAttribute::ExtMap{ id: 1, ext: Extension::AudioLevel }, |
| 1670 | MediaAttribute::ExtMap{ id: 2, ext: Extension::AbsoluteSendTime }, |
| 1671 | MediaAttribute::ExtMap{ id: 3, ext: Extension::TransportSequenceNumber }, |
| 1672 | MediaAttribute::ExtMap{ id: 4, ext: Extension::RtpMid }, |
| 1673 | MediaAttribute::ExtMap{ id: 5, ext: Extension::RtpStreamId }, |
| 1674 | MediaAttribute::ExtMap{ id: 6, ext: Extension::RepairedRtpStreamId }, |
| 1675 | MediaAttribute::SendRecv, |
| 1676 | MediaAttribute::Msid(Msid { |
| 1677 | stream_id: "5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK".into(), |
| 1678 | track_id: "f78dde68-7055-4e20-bb37-433803dd1ed1".into(), |
| 1679 | }), |
| 1680 | MediaAttribute::RtcpMux, |
| 1681 | MediaAttribute::RtpMap { |
| 1682 | pt: 111.into(), |
| 1683 | value: RtpMap { |
| 1684 | codec: "opus".into(), |
| 1685 | clock_rate: Frequency::FORTY_EIGHT_KHZ, |
| 1686 | channels: Some(2), |
| 1687 | }, |
| 1688 | }, |
| 1689 | MediaAttribute::RtcpFb { pt: 111.into(), value: "transport-cc".into() }, |
| 1690 | MediaAttribute::Fmtp { |
| 1691 | pt: 111.into(), |
| 1692 | values: vec![FormatParam::MinPTime(10), FormatParam::UseInbandFec(true)], |
| 1693 | }, |
| 1694 | MediaAttribute::Ssrc { |
| 1695 | ssrc: 3_948_621_874.into(), |
| 1696 | attr: "cname".into(), |
| 1697 | value: "xeXs3aE9AOBn00yJ".into(), |
| 1698 | }, |
| 1699 | MediaAttribute::Ssrc { |
| 1700 | ssrc: 3_948_621_874.into(), |
| 1701 | attr: "msid".into(), |
| 1702 | value: "5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK f78dde68-7055-4e20-bb37-433803dd1ed1" |
| 1703 | .into(), |
| 1704 | }, |
| 1705 | MediaAttribute::Ssrc { |
| 1706 | ssrc: 3_948_621_874.into(), |
| 1707 | attr: "mslabel".into(), |
| 1708 | value: "5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK".into(), |
| 1709 | }, |
| 1710 | MediaAttribute::Ssrc { |
| 1711 | ssrc: 3_948_621_874.into(), |
| 1712 | attr: "label".into(), |
| 1713 | value: "f78dde68-7055-4e20-bb37-433803dd1ed1".into(), |
| 1714 | }, |
| 1715 | ], |
| 1716 | }, |
| 1717 | MediaLine { |
| 1718 | typ: MediaType::Video, |
| 1719 | disabled: false, |
| 1720 | proto: Proto::Srtp, |
| 1721 | pts: vec![45.into(), 46.into()], |
| 1722 | bw: None, |
| 1723 | attrs: vec![ |
| 1724 | MediaAttribute::Rtcp("9 IN IP4 0.0.0.0".into()), |
| 1725 | MediaAttribute::IceUfrag("S5hk".into()), |
| 1726 | MediaAttribute::IcePwd("0zV/Yu3y8aDzbHgqWhnVQhqP".into()), |
| 1727 | MediaAttribute::IceOptions("trickle".into()), |
| 1728 | MediaAttribute::Fingerprint(Fingerprint { |
| 1729 | hash_func: "sha-256".into(), |
| 1730 | bytes: vec![ |
| 1731 | 140, 100, 237, 3, 118, 208, 61, 180, 136, 8, 145, 100, 8, 128, |
| 1732 | 168, 198, 90, 191, 139, 78, 56, 39, 150, 202, 8, 73, 37, 115, |
| 1733 | 70, 96, 32, 220, |
| 1734 | ], |
| 1735 | }), |
| 1736 | MediaAttribute::Setup(Setup::ActPass), |
| 1737 | MediaAttribute::Mid("1".into()), |
| 1738 | MediaAttribute::ExtMap{ id: 14, ext: Extension::TransmissionTimeOffset }, |
| 1739 | MediaAttribute::ExtMap{ id: 2, ext: Extension::AbsoluteSendTime }, |
| 1740 | MediaAttribute::ExtMap{ id: 13, ext: Extension::VideoOrientation }, |
| 1741 | MediaAttribute::ExtMap{ id: 3, ext: Extension::TransportSequenceNumber }, |
| 1742 | MediaAttribute::ExtMap{ id: 5, ext: Extension::PlayoutDelay }, |
| 1743 | MediaAttribute::ExtMap{ id: 6, ext: Extension::VideoContentType }, |
| 1744 | MediaAttribute::ExtMap{ id: 7, ext: Extension::VideoTiming }, |
| 1745 | MediaAttribute::ExtMap{ id: 8, ext: Extension::ColorSpace }, |
| 1746 | MediaAttribute::ExtMap{ id: 4, ext: Extension::RtpMid }, |
| 1747 | MediaAttribute::ExtMap{ id: 10, ext: Extension::RtpStreamId }, |
| 1748 | MediaAttribute::ExtMap{ id: 11, ext: Extension::RepairedRtpStreamId }, |
| 1749 | MediaAttribute::SendRecv, |
| 1750 | MediaAttribute::Msid(Msid { |
| 1751 | stream_id: "-".into(), |
| 1752 | track_id: "4018fd65-ac50-4861-89a4-1f2cc35bbb5e".into(), |
| 1753 | }), |
| 1754 | MediaAttribute::RtcpMux, |
| 1755 | MediaAttribute::RtcpRsize, |
| 1756 | MediaAttribute::RtpMap { |
| 1757 | pt: 45.into(), |
| 1758 | value: RtpMap { |
| 1759 | codec: Codec::Av1, |
| 1760 | clock_rate: Frequency::NINETY_KHZ, |
| 1761 | channels: None, |
| 1762 | }, |
| 1763 | }, |
| 1764 | MediaAttribute::RtcpFb { pt: 45.into(), value: "goog-remb".into() }, |
| 1765 | MediaAttribute::RtcpFb { pt: 45.into(), value: "transport-cc".into() }, |
| 1766 | MediaAttribute::RtcpFb { pt: 45.into(), value: "ccm fir".into() }, |
| 1767 | MediaAttribute::RtcpFb { pt: 45.into(), value: "nack".into() }, |
| 1768 | MediaAttribute::RtcpFb { pt: 45.into(), value: "nack pli".into() }, |
| 1769 | MediaAttribute::Fmtp { |
| 1770 | pt: 45.into(), |
| 1771 | values: vec![ |
| 1772 | FormatParam::LevelIdx(5), |
| 1773 | FormatParam::Profile(0), |
| 1774 | FormatParam::Tier(0), |
| 1775 | ], |
| 1776 | }, |
| 1777 | MediaAttribute::RtpMap { |
| 1778 | pt: 46.into(), |
| 1779 | value: RtpMap { |
| 1780 | codec: Codec::Rtx, |
| 1781 | clock_rate: Frequency::NINETY_KHZ, |
| 1782 | channels: None, |
| 1783 | }, |
| 1784 | }, |
| 1785 | MediaAttribute::Fmtp { pt: 46.into(), values: vec![FormatParam::Apt(45.into())] } |
| 1786 | ], |
| 1787 | } |
| 1788 | ], |
| 1789 | }; |
| 1790 | assert_eq!( |
| 1791 | &format!("{sdp}"), |
| 1792 | &format!( |
| 1793 | "v=0\r\n\ |
| 1794 | o=str0m-{VERSION} 5058682828002148772 2 IN IP4 0.0.0.0\r\n\ |
| 1795 | s=-\r\n\ |
| 1796 | t=0 0\r\n\ |
| 1797 | a=group:BUNDLE 0\r\n\ |
| 1798 | a=msid-semantic: WMS 5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK\r\n\ |
| 1799 | m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126\r\n\ |
| 1800 | c=IN IP4 0.0.0.0\r\n\ |
| 1801 | a=rtcp:9 IN IP4 0.0.0.0\r\n\ |
| 1802 | a=ice-ufrag:S5hk\r\n\ |
| 1803 | a=ice-pwd:0zV/Yu3y8aDzbHgqWhnVQhqP\r\n\ |
| 1804 | a=ice-options:trickle\r\n\ |
| 1805 | a=fingerprint:sha-256 8C:64:ED:03:76:D0:3D:B4:88:08:91:64:08:80:A8:C6:\ |
| 1806 | 5A:BF:8B:4E:38:27:96:CA:08:49:25:73:46:60:20:DC\r\n\ |
| 1807 | a=setup:actpass\r\n\ |
| 1808 | a=mid:0\r\n\ |
| 1809 | a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n\ |
| 1810 | a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n\ |
| 1811 | a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n\ |
| 1812 | a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\ |
| 1813 | a=extmap:5 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n\ |
| 1814 | a=extmap:6 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n\ |
| 1815 | a=sendrecv\r\n\ |
| 1816 | a=msid:5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK f78dde68-7055-4e20-bb37-433803dd1ed1\r\n\ |
| 1817 | a=rtcp-mux\r\n\ |
| 1818 | a=rtpmap:111 opus/48000/2\r\n\ |
| 1819 | a=rtcp-fb:111 transport-cc\r\n\ |
| 1820 | a=fmtp:111 minptime=10;useinbandfec=1\r\n\ |
| 1821 | a=ssrc:3948621874 cname:xeXs3aE9AOBn00yJ\r\n\ |
| 1822 | a=ssrc:3948621874 msid:5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK \ |
| 1823 | f78dde68-7055-4e20-bb37-433803dd1ed1\r\n\ |
| 1824 | a=ssrc:3948621874 mslabel:5UUdwiuY7OML2EkQtF38pJtNP5v7In1LhjEK\r\n\ |
| 1825 | a=ssrc:3948621874 label:f78dde68-7055-4e20-bb37-433803dd1ed1\r\n\ |
| 1826 | m=video 9 UDP/TLS/RTP/SAVPF 45 46\r\n\ |
| 1827 | c=IN IP4 0.0.0.0\r\n\ |
| 1828 | a=rtcp:9 IN IP4 0.0.0.0\r\n\ |
| 1829 | a=ice-ufrag:S5hk\r\n\ |
| 1830 | a=ice-pwd:0zV/Yu3y8aDzbHgqWhnVQhqP\r\n\ |
| 1831 | a=ice-options:trickle\r\n\ |
| 1832 | a=fingerprint:sha-256 8C:64:ED:03:76:D0:3D:B4:88:08:91:64:08:80:A8:C6:\ |
| 1833 | 5A:BF:8B:4E:38:27:96:CA:08:49:25:73:46:60:20:DC\r\n\ |
| 1834 | a=setup:actpass\r\n\ |
| 1835 | a=mid:1\r\n\ |
| 1836 | a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r\n\ |
| 1837 | a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\n\ |
| 1838 | a=extmap:13 urn:3gpp:video-orientation\r\n\ |
| 1839 | a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\r\n\ |
| 1840 | a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\r\n\ |
| 1841 | a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\r\n\ |
| 1842 | a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\r\n\ |
| 1843 | a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\r\n\ |
| 1844 | a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\ |
| 1845 | a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\r\n\ |
| 1846 | a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\r\n\ |
| 1847 | a=sendrecv\r\n\ |
| 1848 | a=msid:- 4018fd65-ac50-4861-89a4-1f2cc35bbb5e\r\n\ |
| 1849 | a=rtcp-mux\r\n\ |
| 1850 | a=rtcp-rsize\r\n\ |
| 1851 | a=rtpmap:45 AV1/90000\r\n\ |
| 1852 | a=rtcp-fb:45 goog-remb\r\n\ |
| 1853 | a=rtcp-fb:45 transport-cc\r\n\ |
| 1854 | a=rtcp-fb:45 ccm fir\r\n\ |
| 1855 | a=rtcp-fb:45 nack\r\n\ |
| 1856 | a=rtcp-fb:45 nack pli\r\n\ |
| 1857 | a=fmtp:45 level-idx=5;profile=0;tier=0\r\n\ |
| 1858 | a=rtpmap:46 rtx/90000\r\n\ |
| 1859 | a=fmtp:46 apt=45\r\n\ |
| 1860 | " |
| 1861 | ) |
| 1862 | ); |
| 1863 | } |
| 1864 | |
| 1865 | #[test] |
| 1866 | fn bundle_mids_returns_correct_mids() { |
| 1867 | let input = "v=0\r\n\ |
| 1868 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 1869 | s=-\r\n\ |
| 1870 | t=0 0\r\n\ |
| 1871 | a=group:BUNDLE 0 1 2\r\n\ |
| 1872 | a=msid-semantic:WMS *\r\n\ |
| 1873 | m=audio 9 UDP/TLS/RTP/SAVPF 111\r\n\ |
| 1874 | c=IN IP4 0.0.0.0\r\n\ |
| 1875 | a=mid:0\r\n\ |
| 1876 | a=sendrecv\r\n\ |
| 1877 | a=rtpmap:111 opus/48000/2\r\n\ |
| 1878 | a=setup:actpass\r\n\ |
| 1879 | a=ice-ufrag:test\r\n\ |
| 1880 | a=ice-pwd:testpassword1234\r\n\ |
| 1881 | m=video 0 UDP/TLS/RTP/SAVPF 96\r\n\ |
| 1882 | c=IN IP4 0.0.0.0\r\n\ |
| 1883 | a=mid:1\r\n\ |
| 1884 | a=sendrecv\r\n\ |
| 1885 | a=rtpmap:96 VP8/90000\r\n\ |
| 1886 | a=setup:actpass\r\n\ |
| 1887 | a=ice-ufrag:test\r\n\ |
| 1888 | a=ice-pwd:testpassword1234\r\n\ |
| 1889 | m=application 0 UDP/DTLS/SCTP webrtc-datachannel\r\n\ |
| 1890 | c=IN IP4 0.0.0.0\r\n\ |
| 1891 | a=mid:2\r\n\ |
| 1892 | a=setup:actpass\r\n\ |
| 1893 | a=ice-ufrag:test\r\n\ |
| 1894 | a=ice-pwd:testpassword1234\r\n\ |
| 1895 | a=sctp-port:5000\r\n\ |
| 1896 | "; |
| 1897 | |
| 1898 | let sdp = Sdp::parse(input).expect("should parse"); |
| 1899 | |
| 1900 | // bundle_mids should return all MIDs from the BUNDLE group |
| 1901 | let mids = sdp.bundle_mids().expect("should have BUNDLE group"); |
| 1902 | assert_eq!(mids.len(), 3); |
| 1903 | assert_eq!(mids[0], "0".into()); |
| 1904 | assert_eq!(mids[1], "1".into()); |
| 1905 | assert_eq!(mids[2], "2".into()); |
| 1906 | |
| 1907 | // The m-lines with port=0 should have disabled=true at parsing level |
| 1908 | // (interpretation of whether this means "rejected" happens later) |
| 1909 | assert!(!sdp.media_lines[0].disabled, "audio has port=9"); |
| 1910 | assert!(sdp.media_lines[1].disabled, "video has port=0"); |
| 1911 | assert!(sdp.media_lines[2].disabled, "application has port=0"); |
| 1912 | } |
| 1913 | } |
| 1914 | |
| 1915 | /// Opus codec-specific tests. |
| 1916 | /// These tests are mainly verify SDP format params. |
| 1917 | mod opus_codec { |
| 1918 | use super::*; |
| 1919 | |
| 1920 | #[test] |
| 1921 | fn opus_fmtp_round_trip() { |
| 1922 | let f = FormatParams { |
| 1923 | use_dtx: Some(true), |
| 1924 | stereo: Some(true), |
| 1925 | sprop_stereo: Some(true), |
| 1926 | ..Default::default() |
| 1927 | }; |
| 1928 | |
| 1929 | let fmtp_str = f.to_string(); |
| 1930 | assert_eq!(fmtp_str, "stereo=1;sprop-stereo=1;usedtx=1"); |
| 1931 | |
| 1932 | let parsed = FormatParams::parse_line(&fmtp_str); |
| 1933 | assert_eq!(parsed, f); |
| 1934 | } |
| 1935 | |
| 1936 | #[test] |
| 1937 | fn opus_default_to_mono() { |
| 1938 | let f = FormatParams { |
| 1939 | ..Default::default() |
| 1940 | }; |
| 1941 | let fmtp_str = f.to_string(); |
| 1942 | assert_eq!(fmtp_str, ""); |
| 1943 | |
| 1944 | let parsed = FormatParams::parse_line(&fmtp_str); |
| 1945 | assert!(parsed.stereo.is_none()); |
| 1946 | assert_eq!(parsed.stereo.unwrap_or(false), false); |
| 1947 | assert_eq!(parsed.sprop_stereo.unwrap_or(false), false); |
| 1948 | } |
| 1949 | } |
| 1950 | |
| 1951 | /// Core H.265 (HEVC) codec-specific tests. |
| 1952 | /// Tests basic H.265 parameter handling including profile-tier-level parsing, |
| 1953 | /// serialization, and format parameter combinations. |
| 1954 | mod h265_codec { |
| 1955 | use super::*; |
| 1956 | |
| 1957 | /// Test that H.265 format parameters can be serialized to a string and parsed back |
| 1958 | /// without loss of information. Verifies the round-trip conversion works correctly. |
| 1959 | #[test] |
| 1960 | fn h265_fmtp_round_trip() { |
| 1961 | // Test that H.265 fmtp params round-trip correctly |
| 1962 | let ptl = H265ProfileTierLevel::new(1, 0, 93).unwrap(); // Main, Main tier, Level 3.1 |
| 1963 | |
| 1964 | let f = FormatParams { |
| 1965 | h265_profile_tier_level: Some(ptl), |
| 1966 | ..Default::default() |
| 1967 | }; |
| 1968 | |
| 1969 | // Serialize to string |
| 1970 | let fmtp_str = f.to_string(); |
| 1971 | assert_eq!(fmtp_str, "profile-id=1;tier-flag=0;level-id=93"); |
| 1972 | |
| 1973 | // Parse back |
| 1974 | let parsed = FormatParams::parse_line(&fmtp_str); |
| 1975 | assert_eq!(parsed.h265_profile_tier_level, Some(ptl)); |
| 1976 | } |
| 1977 | |
| 1978 | /// Test different combinations of H.265 profiles, tiers, and levels to ensure |
| 1979 | /// they are all serialized correctly. Covers Main, Main 10, different tiers, and levels. |
| 1980 | #[test] |
| 1981 | fn h265_different_profiles() { |
| 1982 | // Test Main profile (1) |
| 1983 | let main = H265ProfileTierLevel::new(1, 0, 93).unwrap(); |
| 1984 | let f = FormatParams { |
| 1985 | h265_profile_tier_level: Some(main), |
| 1986 | ..Default::default() |
| 1987 | }; |
| 1988 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=93"); |
| 1989 | |
| 1990 | // Test Main 10 profile (2) |
| 1991 | let main10 = H265ProfileTierLevel::new(2, 0, 93).unwrap(); |
| 1992 | let f = FormatParams { |
| 1993 | h265_profile_tier_level: Some(main10), |
| 1994 | ..Default::default() |
| 1995 | }; |
| 1996 | assert_eq!(f.to_string(), "profile-id=2;tier-flag=0;level-id=93"); |
| 1997 | |
| 1998 | // Test different tier (High tier) |
| 1999 | let high_tier = H265ProfileTierLevel::new(1, 1, 93).unwrap(); |
| 2000 | let f = FormatParams { |
| 2001 | h265_profile_tier_level: Some(high_tier), |
| 2002 | ..Default::default() |
| 2003 | }; |
| 2004 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=1;level-id=93"); |
| 2005 | |
| 2006 | // Test different level (Level 5.1 = 153) |
| 2007 | let level_5_1 = H265ProfileTierLevel::new(1, 0, 153).unwrap(); |
| 2008 | let f = FormatParams { |
| 2009 | h265_profile_tier_level: Some(level_5_1), |
| 2010 | ..Default::default() |
| 2011 | }; |
| 2012 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=153"); |
| 2013 | } |
| 2014 | |
| 2015 | /// Test parsing H.265 parameters from key-value pairs as they would appear in SDP. |
| 2016 | /// Verifies that the three separate params (profile-id, tier-flag, level-id) are |
| 2017 | /// correctly combined into a single H265ProfileTierLevel composite. |
| 2018 | #[test] |
| 2019 | fn h265_parse_from_pairs() { |
| 2020 | // Test parsing from key-value pairs (as would come from SDP) |
| 2021 | let pairs = vec![ |
| 2022 | ("profile-id".to_string(), "1".to_string()), |
| 2023 | ("tier-flag".to_string(), "0".to_string()), |
| 2024 | ("level-id".to_string(), "93".to_string()), |
| 2025 | ]; |
| 2026 | |
| 2027 | let params = FormatParam::parse_pairs(pairs); |
| 2028 | |
| 2029 | // Should have exactly one H265ProfileTierLevel param |
| 2030 | let h265_params: Vec<_> = params |
| 2031 | .iter() |
| 2032 | .filter(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2033 | .collect(); |
| 2034 | assert_eq!(h265_params.len(), 1); |
| 2035 | |
| 2036 | if let FormatParam::H265ProfileTierLevel(ptl) = h265_params[0] { |
| 2037 | assert_eq!(ptl.profile_id(), 1); |
| 2038 | assert_eq!(ptl.tier_flag(), 0); |
| 2039 | assert_eq!(ptl.level_id(), 93); |
| 2040 | } else { |
| 2041 | panic!("Expected H265ProfileTierLevel"); |
| 2042 | } |
| 2043 | } |
| 2044 | |
| 2045 | /// Test that H.265 profile-tier-level params can coexist with other H.265-specific |
| 2046 | /// parameters like sprop-max-don-diff without interfering with each other. |
| 2047 | #[test] |
| 2048 | fn h265_mixed_with_other_params() { |
| 2049 | // Test H.265 params mixed with other format params |
| 2050 | let pairs = vec![ |
| 2051 | ("profile-id".to_string(), "1".to_string()), |
| 2052 | ("tier-flag".to_string(), "0".to_string()), |
| 2053 | ("level-id".to_string(), "93".to_string()), |
| 2054 | ("sprop-max-don-diff".to_string(), "0".to_string()), |
| 2055 | ]; |
| 2056 | |
| 2057 | let params = FormatParam::parse_pairs(pairs); |
| 2058 | |
| 2059 | // Should have H265ProfileTierLevel and SpropMaxDonDiff |
| 2060 | assert!( |
| 2061 | params |
| 2062 | .iter() |
| 2063 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2064 | ); |
| 2065 | assert!( |
| 2066 | params |
| 2067 | .iter() |
| 2068 | .any(|p| matches!(p, FormatParam::SpropMaxDonDiff(0))) |
| 2069 | ); |
| 2070 | } |
| 2071 | |
| 2072 | /// Test sprop-max-don-diff parsing, display, and round-trip for various values. |
| 2073 | #[test] |
| 2074 | fn sprop_max_don_diff_parse_and_display() { |
| 2075 | // Parse standalone sprop-max-don-diff |
| 2076 | let param = FormatParam::parse("sprop-max-don-diff", "32"); |
| 2077 | assert!(matches!(param, FormatParam::SpropMaxDonDiff(32))); |
| 2078 | |
| 2079 | // Display format |
| 2080 | assert_eq!(param.to_string(), "sprop-max-don-diff=32"); |
| 2081 | |
| 2082 | // Parse value 0 (DONL disabled) |
| 2083 | let param0 = FormatParam::parse("sprop-max-don-diff", "0"); |
| 2084 | assert!(matches!(param0, FormatParam::SpropMaxDonDiff(0))); |
| 2085 | |
| 2086 | // Parse max valid value (RFC 7798 ยง7.1: 0..32767) |
| 2087 | let param_max = FormatParam::parse("sprop-max-don-diff", "32767"); |
| 2088 | assert!(matches!(param_max, FormatParam::SpropMaxDonDiff(32767))); |
| 2089 | |
| 2090 | // Out-of-range value (32768) rejected per RFC 7798 ยง7.1 |
| 2091 | let param_over = FormatParam::parse("sprop-max-don-diff", "32768"); |
| 2092 | assert!(matches!(param_over, FormatParam::Unknown)); |
| 2093 | |
| 2094 | // Invalid value falls back to Unknown |
| 2095 | let param_bad = FormatParam::parse("sprop-max-don-diff", "notanumber"); |
| 2096 | assert!(matches!(param_bad, FormatParam::Unknown)); |
| 2097 | } |
| 2098 | |
| 2099 | /// Test that sprop-max-don-diff > 0 round-trips through H.265 fmtp with PTL params. |
| 2100 | #[test] |
| 2101 | fn h265_sprop_max_don_diff_nonzero_with_ptl() { |
| 2102 | let pairs = vec![ |
| 2103 | ("profile-id".to_string(), "1".to_string()), |
| 2104 | ("tier-flag".to_string(), "0".to_string()), |
| 2105 | ("level-id".to_string(), "93".to_string()), |
| 2106 | ("sprop-max-don-diff".to_string(), "32".to_string()), |
| 2107 | ]; |
| 2108 | |
| 2109 | let params = FormatParam::parse_pairs(pairs); |
| 2110 | |
| 2111 | // Should have both H265ProfileTierLevel and SpropMaxDonDiff(32) |
| 2112 | assert!( |
| 2113 | params |
| 2114 | .iter() |
| 2115 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2116 | ); |
| 2117 | assert!( |
| 2118 | params |
| 2119 | .iter() |
| 2120 | .any(|p| matches!(p, FormatParam::SpropMaxDonDiff(32))) |
| 2121 | ); |
| 2122 | |
| 2123 | // Verify PTL values are correct |
| 2124 | if let Some(FormatParam::H265ProfileTierLevel(ptl)) = params |
| 2125 | .iter() |
| 2126 | .find(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2127 | { |
| 2128 | assert_eq!(ptl.profile_id(), 1); |
| 2129 | assert_eq!(ptl.tier_flag(), 0); |
| 2130 | assert_eq!(ptl.level_id(), 93); |
| 2131 | } |
| 2132 | } |
| 2133 | } |
| 2134 | |
| 2135 | /// Tests ensuring H.265 parameters are not confused with other codecs. |
| 2136 | /// Verifies that H.265's three-parameter format (profile-id, tier-flag, level-id) |
| 2137 | /// is correctly distinguished from H.264's profile-level-id, VP9's profile-id, |
| 2138 | /// and AV1's profile/tier/level-idx parameters. |
| 2139 | mod no_confusion { |
| 2140 | use super::*; |
| 2141 | |
| 2142 | /// Test that H.264's profile-level-id parameter (single hex value) is not confused |
| 2143 | /// with H.265's three separate parameters. H.264 uses a different format entirely. |
| 2144 | #[test] |
| 2145 | fn h265_no_confusion_with_h264() { |
| 2146 | // H.264 uses profile-level-id (single param) |
| 2147 | let h264_pairs = vec![ |
| 2148 | ("profile-level-id".to_string(), "42e01f".to_string()), |
| 2149 | ("packetization-mode".to_string(), "1".to_string()), |
| 2150 | ]; |
| 2151 | |
| 2152 | let h264_params = FormatParam::parse_pairs(h264_pairs); |
| 2153 | |
| 2154 | // Should NOT have H265ProfileTierLevel |
| 2155 | assert!( |
| 2156 | !h264_params |
| 2157 | .iter() |
| 2158 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2159 | ); |
| 2160 | |
| 2161 | // Should have ProfileLevelId (H.264) |
| 2162 | assert!( |
| 2163 | h264_params |
| 2164 | .iter() |
| 2165 | .any(|p| matches!(p, FormatParam::ProfileLevelId(_))) |
| 2166 | ); |
| 2167 | assert!( |
| 2168 | h264_params |
| 2169 | .iter() |
| 2170 | .any(|p| matches!(p, FormatParam::PacketizationMode(1))) |
| 2171 | ); |
| 2172 | } |
| 2173 | |
| 2174 | /// Test that VP9's profile-id parameter (standalone) is not confused with H.265's |
| 2175 | /// profile-id which must be combined with tier-flag and level-id. |
| 2176 | #[test] |
| 2177 | fn h265_no_confusion_with_vp9() { |
| 2178 | // VP9 uses profile-id (single param, not combined) |
| 2179 | let vp9_pairs = vec![("profile-id".to_string(), "0".to_string())]; |
| 2180 | |
| 2181 | let vp9_params = FormatParam::parse_pairs(vp9_pairs); |
| 2182 | |
| 2183 | // Should NOT have H265ProfileTierLevel (missing tier-flag and level-id) |
| 2184 | assert!( |
| 2185 | !vp9_params |
| 2186 | .iter() |
| 2187 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2188 | ); |
| 2189 | |
| 2190 | // Should have ProfileId (VP9) |
| 2191 | assert!( |
| 2192 | vp9_params |
| 2193 | .iter() |
| 2194 | .any(|p| matches!(p, FormatParam::ProfileId(0))) |
| 2195 | ); |
| 2196 | } |
| 2197 | |
| 2198 | /// Test that AV1's parameters (profile, tier, level-idx) use different naming than |
| 2199 | /// H.265 (profile-id, tier-flag, level-id) and are not confused during parsing. |
| 2200 | #[test] |
| 2201 | fn h265_no_confusion_with_av1() { |
| 2202 | // AV1 uses profile, level-idx, tier (but should not be confused with H.265) |
| 2203 | let av1_pairs = vec![ |
| 2204 | ("profile".to_string(), "0".to_string()), |
| 2205 | ("level-idx".to_string(), "5".to_string()), |
| 2206 | ("tier".to_string(), "0".to_string()), |
| 2207 | ]; |
| 2208 | |
| 2209 | let av1_params = FormatParam::parse_pairs(av1_pairs); |
| 2210 | |
| 2211 | // Should NOT have H265ProfileTierLevel |
| 2212 | // (AV1 uses different param names: profile vs profile-id, |
| 2213 | // tier vs tier-flag, level-idx vs level-id) |
| 2214 | assert!( |
| 2215 | !av1_params |
| 2216 | .iter() |
| 2217 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2218 | ); |
| 2219 | |
| 2220 | // Should have AV1-specific params |
| 2221 | assert!( |
| 2222 | av1_params |
| 2223 | .iter() |
| 2224 | .any(|p| matches!(p, FormatParam::Profile(0))) |
| 2225 | ); |
| 2226 | assert!( |
| 2227 | av1_params |
| 2228 | .iter() |
| 2229 | .any(|p| matches!(p, FormatParam::LevelIdx(5))) |
| 2230 | ); |
| 2231 | assert!(av1_params.iter().any(|p| matches!(p, FormatParam::Tier(0)))); |
| 2232 | } |
| 2233 | } |
| 2234 | |
| 2235 | /// Additional H.265 parsing and validation tests. |
| 2236 | /// Covers incomplete parameter handling and complete SDP integration scenarios. |
| 2237 | mod h265_additional { |
| 2238 | use super::*; |
| 2239 | |
| 2240 | /// Test that incomplete H.265 parameter sets do not create a composite ProfileTierLevel. |
| 2241 | /// All three parameters (profile-id, tier-flag, level-id) must be present. |
| 2242 | #[test] |
| 2243 | fn h265_partial_params_no_composite() { |
| 2244 | // If only some H.265 params are present, should not create composite |
| 2245 | let partial_pairs = vec![ |
| 2246 | ("profile-id".to_string(), "1".to_string()), |
| 2247 | ("tier-flag".to_string(), "0".to_string()), |
| 2248 | // Missing level-id |
| 2249 | ]; |
| 2250 | |
| 2251 | let params = FormatParam::parse_pairs(partial_pairs); |
| 2252 | |
| 2253 | // Should NOT have H265ProfileTierLevel (incomplete) |
| 2254 | assert!( |
| 2255 | !params |
| 2256 | .iter() |
| 2257 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2258 | ); |
| 2259 | } |
| 2260 | |
| 2261 | /// Test H.265 parameters in a complete SDP offer/answer scenario. |
| 2262 | /// Verifies that H.265 rtpmap and fmtp lines are correctly parsed along with RTX. |
| 2263 | #[test] |
| 2264 | fn h265_in_complete_sdp() { |
| 2265 | let input = "v=0\r\n\ |
| 2266 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 2267 | s=-\r\n\ |
| 2268 | t=0 0\r\n\ |
| 2269 | a=group:BUNDLE 0\r\n\ |
| 2270 | m=video 9 UDP/TLS/RTP/SAVPF 96 97\r\n\ |
| 2271 | c=IN IP4 0.0.0.0\r\n\ |
| 2272 | a=mid:0\r\n\ |
| 2273 | a=sendrecv\r\n\ |
| 2274 | a=rtpmap:96 H265/90000\r\n\ |
| 2275 | a=fmtp:96 profile-id=1;tier-flag=0;level-id=93\r\n\ |
| 2276 | a=rtpmap:97 rtx/90000\r\n\ |
| 2277 | a=fmtp:97 apt=96\r\n\ |
| 2278 | a=setup:actpass\r\n\ |
| 2279 | a=ice-ufrag:test\r\n\ |
| 2280 | a=ice-pwd:testpassword\r\n\ |
| 2281 | "; |
| 2282 | |
| 2283 | let sdp = Sdp::parse(input).expect("should parse"); |
| 2284 | |
| 2285 | // Get RTP params |
| 2286 | let params = sdp.media_lines[0].rtp_params(); |
| 2287 | |
| 2288 | // Find H.265 payload |
| 2289 | let h265_payload = params |
| 2290 | .iter() |
| 2291 | .find(|p| p.spec.codec == Codec::H265) |
| 2292 | .expect("should have H.265"); |
| 2293 | |
| 2294 | assert_eq!(h265_payload.pt, 96.into()); |
| 2295 | assert_eq!(h265_payload.spec.clock_rate, Frequency::NINETY_KHZ); |
| 2296 | |
| 2297 | // Check H.265 params |
| 2298 | let ptl = h265_payload |
| 2299 | .spec |
| 2300 | .format |
| 2301 | .h265_profile_tier_level |
| 2302 | .expect("should have H.265 PTL"); |
| 2303 | assert_eq!(ptl.profile_id(), 1); |
| 2304 | assert_eq!(ptl.tier_flag(), 0); |
| 2305 | assert_eq!(ptl.level_id(), 93); |
| 2306 | |
| 2307 | // Check RTX |
| 2308 | assert_eq!(h265_payload.resend, Some(97.into())); |
| 2309 | } |
| 2310 | } |
| 2311 | |
| 2312 | /// Multi-codec integration tests. |
| 2313 | /// Verifies that multiple video codecs (H.264, H.265, VP8, VP9) can coexist |
| 2314 | /// in the same SDP without parameter conflicts or interference. |
| 2315 | mod multi_codec { |
| 2316 | use super::*; |
| 2317 | |
| 2318 | /// Test that H.264, H.265, and other video codecs can coexist in the same SDP |
| 2319 | /// without their format parameters interfering with each other. |
| 2320 | #[test] |
| 2321 | fn h265_and_h264_coexist() { |
| 2322 | let input = "v=0\r\n\ |
| 2323 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 2324 | s=-\r\n\ |
| 2325 | t=0 0\r\n\ |
| 2326 | a=group:BUNDLE 0\r\n\ |
| 2327 | m=video 9 UDP/TLS/RTP/SAVPF 96 97 98\r\n\ |
| 2328 | c=IN IP4 0.0.0.0\r\n\ |
| 2329 | a=mid:0\r\n\ |
| 2330 | a=sendrecv\r\n\ |
| 2331 | a=rtpmap:96 H264/90000\r\n\ |
| 2332 | a=fmtp:96 profile-level-id=42e01f;packetization-mode=1\r\n\ |
| 2333 | a=rtpmap:97 H265/90000\r\n\ |
| 2334 | a=fmtp:97 profile-id=1;tier-flag=0;level-id=93\r\n\ |
| 2335 | a=rtpmap:98 VP8/90000\r\n\ |
| 2336 | a=setup:actpass\r\n\ |
| 2337 | a=ice-ufrag:test\r\n\ |
| 2338 | a=ice-pwd:testpassword\r\n\ |
| 2339 | "; |
| 2340 | |
| 2341 | let sdp = Sdp::parse(input).expect("should parse"); |
| 2342 | let params = sdp.media_lines[0].rtp_params(); |
| 2343 | |
| 2344 | // Check H.264 |
| 2345 | let h264 = params |
| 2346 | .iter() |
| 2347 | .find(|p| p.spec.codec == Codec::H264) |
| 2348 | .expect("should have H.264"); |
| 2349 | assert_eq!(h264.pt, 96.into()); |
| 2350 | assert_eq!(h264.spec.format.profile_level_id, Some(0x42e01f)); |
| 2351 | assert_eq!(h264.spec.format.packetization_mode, Some(1)); |
| 2352 | assert!(h264.spec.format.h265_profile_tier_level.is_none()); |
| 2353 | |
| 2354 | // Check H.265 |
| 2355 | let h265 = params |
| 2356 | .iter() |
| 2357 | .find(|p| p.spec.codec == Codec::H265) |
| 2358 | .expect("should have H.265"); |
| 2359 | assert_eq!(h265.pt, 97.into()); |
| 2360 | let ptl = h265 |
| 2361 | .spec |
| 2362 | .format |
| 2363 | .h265_profile_tier_level |
| 2364 | .expect("should have H.265 PTL"); |
| 2365 | assert_eq!(ptl.profile_id(), 1); |
| 2366 | assert!(h265.spec.format.profile_level_id.is_none()); |
| 2367 | |
| 2368 | // Check VP8 |
| 2369 | let vp8 = params |
| 2370 | .iter() |
| 2371 | .find(|p| p.spec.codec == Codec::Vp8) |
| 2372 | .expect("should have VP8"); |
| 2373 | assert_eq!(vp8.pt, 98.into()); |
| 2374 | assert!(vp8.spec.format.h265_profile_tier_level.is_none()); |
| 2375 | assert!(vp8.spec.format.profile_level_id.is_none()); |
| 2376 | } |
| 2377 | } |
| 2378 | |
| 2379 | /// H.265 serialization and edge case tests. |
| 2380 | /// Tests media attribute generation, empty parameter handling, extreme values, |
| 2381 | /// parameter ordering independence, DONL parameters, and invalid value handling. |
| 2382 | mod h265_serialization { |
| 2383 | use super::*; |
| 2384 | |
| 2385 | /// Test that H.265 parameters are correctly serialized as media attributes, |
| 2386 | /// including rtpmap, fmtp with ProfileTierLevel, rtcp-fb, and RTX payload. |
| 2387 | #[test] |
| 2388 | fn h265_serialization_in_media_attrs() { |
| 2389 | let ptl = H265ProfileTierLevel::new(2, 1, 153).unwrap(); // Main 10, High tier, Level 5.1 |
| 2390 | |
| 2391 | let mut payload = PayloadParams::new( |
| 2392 | 100.into(), |
| 2393 | Some(101.into()), |
| 2394 | CodecSpec { |
| 2395 | codec: Codec::H265, |
| 2396 | clock_rate: Frequency::NINETY_KHZ, |
| 2397 | channels: None, |
| 2398 | format: FormatParams { |
| 2399 | h265_profile_tier_level: Some(ptl), |
| 2400 | ..Default::default() |
| 2401 | }, |
| 2402 | }, |
| 2403 | ); |
| 2404 | payload.fb_transport_cc = true; |
| 2405 | payload.fb_nack = true; |
| 2406 | payload.fb_pli = true; |
| 2407 | |
| 2408 | let mut attrs = vec![]; |
| 2409 | payload.as_media_attrs(&mut attrs); |
| 2410 | |
| 2411 | // Should have rtpmap for H.265 |
| 2412 | assert!(attrs.iter().any(|a| matches!(a, |
| 2413 | MediaAttribute::RtpMap { pt, value } |
| 2414 | if *pt == 100.into() && value.codec == Codec::H265 |
| 2415 | ))); |
| 2416 | |
| 2417 | // Should have fmtp with H.265 params |
| 2418 | let fmtp = attrs.iter().find(|a| { |
| 2419 | matches!(a, |
| 2420 | MediaAttribute::Fmtp { pt, .. } if *pt == 100.into() |
| 2421 | ) |
| 2422 | }); |
| 2423 | assert!(fmtp.is_some()); |
| 2424 | |
| 2425 | if let Some(MediaAttribute::Fmtp { values, .. }) = fmtp { |
| 2426 | assert!( |
| 2427 | values |
| 2428 | .iter() |
| 2429 | .any(|v| matches!(v, FormatParam::H265ProfileTierLevel(p) |
| 2430 | if p.profile_id() == 2 && p.tier_flag() == 1 && p.level_id() == 153 |
| 2431 | )) |
| 2432 | ); |
| 2433 | } |
| 2434 | |
| 2435 | // Should have rtx mapping |
| 2436 | assert!(attrs.iter().any(|a| matches!(a, |
| 2437 | MediaAttribute::RtpMap { pt, value } |
| 2438 | if *pt == 101.into() && value.codec == Codec::Rtx |
| 2439 | ))); |
| 2440 | } |
| 2441 | |
| 2442 | /// Test that empty format parameters do not generate an a=fmtp line in SDP, |
| 2443 | /// avoiding invalid SDP syntax with empty fmtp attributes. |
| 2444 | #[test] |
| 2445 | fn h265_empty_fmtp_not_serialized() { |
| 2446 | // Empty fmtp should not be written |
| 2447 | let payload = PayloadParams::new( |
| 2448 | 100.into(), |
| 2449 | None, |
| 2450 | CodecSpec { |
| 2451 | codec: Codec::H265, |
| 2452 | clock_rate: Frequency::NINETY_KHZ, |
| 2453 | channels: None, |
| 2454 | format: FormatParams::default(), |
| 2455 | }, |
| 2456 | ); |
| 2457 | |
| 2458 | let mut attrs = vec![]; |
| 2459 | payload.as_media_attrs(&mut attrs); |
| 2460 | |
| 2461 | // Should NOT have fmtp line (it would be empty) |
| 2462 | assert!( |
| 2463 | !attrs |
| 2464 | .iter() |
| 2465 | .any(|a| matches!(a, MediaAttribute::Fmtp { .. })) |
| 2466 | ); |
| 2467 | } |
| 2468 | |
| 2469 | /// Test H.265 with extreme profile/tier/level values at boundaries. |
| 2470 | /// Verifies parsing and serialization of edge case values. |
| 2471 | #[test] |
| 2472 | fn h265_extreme_values() { |
| 2473 | // Test minimum level (Level 1.0 = 30) |
| 2474 | let min_level = H265ProfileTierLevel::new(1, 0, 30).unwrap(); |
| 2475 | let f = FormatParams { |
| 2476 | h265_profile_tier_level: Some(min_level), |
| 2477 | ..Default::default() |
| 2478 | }; |
| 2479 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=30"); |
| 2480 | |
| 2481 | // Test maximum level (Level 6.2 = 186) |
| 2482 | let max_level = H265ProfileTierLevel::new(1, 0, 186).unwrap(); |
| 2483 | let f = FormatParams { |
| 2484 | h265_profile_tier_level: Some(max_level), |
| 2485 | ..Default::default() |
| 2486 | }; |
| 2487 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=186"); |
| 2488 | |
| 2489 | // Test Main10 with High tier |
| 2490 | let main10_high = H265ProfileTierLevel::new(2, 1, 93).unwrap(); |
| 2491 | let f = FormatParams { |
| 2492 | h265_profile_tier_level: Some(main10_high), |
| 2493 | ..Default::default() |
| 2494 | }; |
| 2495 | assert_eq!(f.to_string(), "profile-id=2;tier-flag=1;level-id=93"); |
| 2496 | } |
| 2497 | |
| 2498 | /// Test H.265 parameter ordering doesn't affect parsing. |
| 2499 | /// SDP parameters can appear in any order. |
| 2500 | #[test] |
| 2501 | fn h265_parameter_order_independence() { |
| 2502 | // Normal order |
| 2503 | let normal_order = vec![ |
| 2504 | ("profile-id".to_string(), "1".to_string()), |
| 2505 | ("tier-flag".to_string(), "0".to_string()), |
| 2506 | ("level-id".to_string(), "93".to_string()), |
| 2507 | ]; |
| 2508 | |
| 2509 | // Reversed order |
| 2510 | let reversed_order = vec![ |
| 2511 | ("level-id".to_string(), "93".to_string()), |
| 2512 | ("tier-flag".to_string(), "0".to_string()), |
| 2513 | ("profile-id".to_string(), "1".to_string()), |
| 2514 | ]; |
| 2515 | |
| 2516 | // Mixed order |
| 2517 | let mixed_order = vec![ |
| 2518 | ("tier-flag".to_string(), "0".to_string()), |
| 2519 | ("profile-id".to_string(), "1".to_string()), |
| 2520 | ("level-id".to_string(), "93".to_string()), |
| 2521 | ]; |
| 2522 | |
| 2523 | let normal_params = FormatParam::parse_pairs(normal_order); |
| 2524 | let reversed_params = FormatParam::parse_pairs(reversed_order); |
| 2525 | let mixed_params = FormatParam::parse_pairs(mixed_order); |
| 2526 | |
| 2527 | // All should create H265ProfileTierLevel |
| 2528 | assert!( |
| 2529 | normal_params |
| 2530 | .iter() |
| 2531 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2532 | ); |
| 2533 | assert!( |
| 2534 | reversed_params |
| 2535 | .iter() |
| 2536 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2537 | ); |
| 2538 | assert!( |
| 2539 | mixed_params |
| 2540 | .iter() |
| 2541 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2542 | ); |
| 2543 | |
| 2544 | // All should have the same values |
| 2545 | for params in [&normal_params, &reversed_params, &mixed_params] { |
| 2546 | if let Some(FormatParam::H265ProfileTierLevel(ptl)) = params |
| 2547 | .iter() |
| 2548 | .find(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2549 | { |
| 2550 | assert_eq!(ptl.profile_id(), 1); |
| 2551 | assert_eq!(ptl.tier_flag(), 0); |
| 2552 | assert_eq!(ptl.level_id(), 93); |
| 2553 | } |
| 2554 | } |
| 2555 | } |
| 2556 | |
| 2557 | #[test] |
| 2558 | fn h265_invalid_parameter_values() { |
| 2559 | // Invalid profile-id |
| 2560 | let invalid_profile = vec![ |
| 2561 | ("profile-id".to_string(), "999".to_string()), |
| 2562 | ("tier-flag".to_string(), "0".to_string()), |
| 2563 | ("level-id".to_string(), "93".to_string()), |
| 2564 | ]; |
| 2565 | let params = FormatParam::parse_pairs(invalid_profile); |
| 2566 | assert!( |
| 2567 | !params |
| 2568 | .iter() |
| 2569 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2570 | ); |
| 2571 | |
| 2572 | // Invalid tier-flag |
| 2573 | let invalid_tier = vec![ |
| 2574 | ("profile-id".to_string(), "1".to_string()), |
| 2575 | ("tier-flag".to_string(), "5".to_string()), |
| 2576 | ("level-id".to_string(), "93".to_string()), |
| 2577 | ]; |
| 2578 | let params = FormatParam::parse_pairs(invalid_tier); |
| 2579 | assert!( |
| 2580 | !params |
| 2581 | .iter() |
| 2582 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2583 | ); |
| 2584 | |
| 2585 | // Invalid level-id |
| 2586 | let invalid_level = vec![ |
| 2587 | ("profile-id".to_string(), "1".to_string()), |
| 2588 | ("tier-flag".to_string(), "0".to_string()), |
| 2589 | ("level-id".to_string(), "999".to_string()), |
| 2590 | ]; |
| 2591 | let params = FormatParam::parse_pairs(invalid_level); |
| 2592 | assert!( |
| 2593 | !params |
| 2594 | .iter() |
| 2595 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2596 | ); |
| 2597 | } |
| 2598 | } |
| 2599 | |
| 2600 | /// H.265 integration with RTCP feedback mechanisms. |
| 2601 | /// Tests H.265 behavior with multiple codecs and various RTCP feedback types |
| 2602 | /// including NACK, PLI, FIR, and transport-cc. |
| 2603 | mod h265_integration { |
| 2604 | use super::*; |
| 2605 | |
| 2606 | /// Test H.265 in SDP with multiple video codecs and feedback mechanisms. |
| 2607 | /// Verifies proper parsing of complex multi-codec scenarios. |
| 2608 | #[test] |
| 2609 | fn h265_multi_codec_with_feedback() { |
| 2610 | let input = "v=0\r\n\ |
| 2611 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 2612 | s=-\r\n\ |
| 2613 | t=0 0\r\n\ |
| 2614 | a=group:BUNDLE 0\r\n\ |
| 2615 | m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99\r\n\ |
| 2616 | c=IN IP4 0.0.0.0\r\n\ |
| 2617 | a=mid:0\r\n\ |
| 2618 | a=sendrecv\r\n\ |
| 2619 | a=rtpmap:96 H265/90000\r\n\ |
| 2620 | a=fmtp:96 profile-id=1;tier-flag=0;level-id=93\r\n\ |
| 2621 | a=rtcp-fb:96 nack\r\n\ |
| 2622 | a=rtcp-fb:96 nack pli\r\n\ |
| 2623 | a=rtcp-fb:96 ccm fir\r\n\ |
| 2624 | a=rtcp-fb:96 transport-cc\r\n\ |
| 2625 | a=rtpmap:97 rtx/90000\r\n\ |
| 2626 | a=fmtp:97 apt=96\r\n\ |
| 2627 | a=rtpmap:98 VP9/90000\r\n\ |
| 2628 | a=fmtp:98 profile-id=0\r\n\ |
| 2629 | a=rtpmap:99 rtx/90000\r\n\ |
| 2630 | a=fmtp:99 apt=98\r\n\ |
| 2631 | a=setup:actpass\r\n\ |
| 2632 | a=ice-ufrag:test\r\n\ |
| 2633 | a=ice-pwd:testpassword\r\n\ |
| 2634 | "; |
| 2635 | |
| 2636 | let sdp = Sdp::parse(input).expect("should parse"); |
| 2637 | let params = sdp.media_lines[0].rtp_params(); |
| 2638 | |
| 2639 | // Check H.265 with all feedback mechanisms |
| 2640 | let h265 = params |
| 2641 | .iter() |
| 2642 | .find(|p| p.spec.codec == Codec::H265) |
| 2643 | .expect("should have H.265"); |
| 2644 | assert_eq!(h265.pt, 96.into()); |
| 2645 | assert!(h265.fb_nack); |
| 2646 | assert!(h265.fb_pli); |
| 2647 | assert!(h265.fb_fir); |
| 2648 | assert!(h265.fb_transport_cc); |
| 2649 | assert_eq!(h265.resend, Some(97.into())); |
| 2650 | |
| 2651 | // Verify H.265 PTL is correct |
| 2652 | let ptl = h265 |
| 2653 | .spec |
| 2654 | .format |
| 2655 | .h265_profile_tier_level |
| 2656 | .expect("should have PTL"); |
| 2657 | assert_eq!(ptl.profile_id(), 1); |
| 2658 | |
| 2659 | // Check VP9 is separate and correct |
| 2660 | let vp9 = params |
| 2661 | .iter() |
| 2662 | .find(|p| p.spec.codec == Codec::Vp9) |
| 2663 | .expect("should have VP9"); |
| 2664 | assert_eq!(vp9.pt, 98.into()); |
| 2665 | assert_eq!(vp9.spec.format.profile_id, Some(0)); |
| 2666 | assert!(vp9.spec.format.h265_profile_tier_level.is_none()); |
| 2667 | } |
| 2668 | } |
| 2669 | |
| 2670 | /// Advanced H.265 functionality tests. |
| 2671 | /// Covers SDP round-trip serialization, incomplete parameter permutations, |
| 2672 | /// browser compatibility (Chrome profile-only mode), and validation of all |
| 2673 | /// standard H.265 level values (1.0 through 6.2). |
| 2674 | mod h265_advanced { |
| 2675 | use super::*; |
| 2676 | |
| 2677 | /// Test H.265 SDP serialization maintains parameter integrity. |
| 2678 | /// Ensures that serialized SDP can be parsed back with identical values. |
| 2679 | #[test] |
| 2680 | fn h265_sdp_serialization_round_trip() { |
| 2681 | let ptl = H265ProfileTierLevel::new(2, 0, 153).unwrap(); |
| 2682 | |
| 2683 | let payload = PayloadParams::new( |
| 2684 | 100.into(), |
| 2685 | Some(101.into()), |
| 2686 | CodecSpec { |
| 2687 | codec: Codec::H265, |
| 2688 | clock_rate: Frequency::NINETY_KHZ, |
| 2689 | channels: None, |
| 2690 | format: FormatParams { |
| 2691 | h265_profile_tier_level: Some(ptl), |
| 2692 | ..Default::default() |
| 2693 | }, |
| 2694 | }, |
| 2695 | ); |
| 2696 | |
| 2697 | let mut attrs = vec![]; |
| 2698 | payload.as_media_attrs(&mut attrs); |
| 2699 | |
| 2700 | // Find the fmtp line |
| 2701 | let fmtp = attrs |
| 2702 | .iter() |
| 2703 | .find_map(|a| match a { |
| 2704 | MediaAttribute::Fmtp { pt, values } if *pt == 100.into() => Some(values), |
| 2705 | _ => None, |
| 2706 | }) |
| 2707 | .expect("should have fmtp"); |
| 2708 | |
| 2709 | // Should contain H265ProfileTierLevel |
| 2710 | assert!( |
| 2711 | fmtp.iter().any( |
| 2712 | |v| matches!(v, FormatParam::H265ProfileTierLevel(p) if p.profile_id() == 2) |
| 2713 | ) |
| 2714 | ); |
| 2715 | } |
| 2716 | |
| 2717 | /// Test that only two of three H.265 params doesn't create PTL. |
| 2718 | /// Each permutation of missing parameter should fail. |
| 2719 | #[test] |
| 2720 | fn h265_incomplete_params_all_permutations() { |
| 2721 | // Missing level-id |
| 2722 | let missing_level = vec![ |
| 2723 | ("profile-id".to_string(), "1".to_string()), |
| 2724 | ("tier-flag".to_string(), "0".to_string()), |
| 2725 | ]; |
| 2726 | let params = FormatParam::parse_pairs(missing_level); |
| 2727 | assert!( |
| 2728 | !params |
| 2729 | .iter() |
| 2730 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2731 | ); |
| 2732 | |
| 2733 | // Missing tier-flag |
| 2734 | let missing_tier = vec![ |
| 2735 | ("profile-id".to_string(), "1".to_string()), |
| 2736 | ("level-id".to_string(), "93".to_string()), |
| 2737 | ]; |
| 2738 | let params = FormatParam::parse_pairs(missing_tier); |
| 2739 | assert!( |
| 2740 | !params |
| 2741 | .iter() |
| 2742 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2743 | ); |
| 2744 | |
| 2745 | // Missing profile-id |
| 2746 | let missing_profile = vec![ |
| 2747 | ("tier-flag".to_string(), "0".to_string()), |
| 2748 | ("level-id".to_string(), "93".to_string()), |
| 2749 | ]; |
| 2750 | let params = FormatParam::parse_pairs(missing_profile); |
| 2751 | assert!( |
| 2752 | !params |
| 2753 | .iter() |
| 2754 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2755 | ); |
| 2756 | |
| 2757 | // Only one parameter |
| 2758 | let only_profile = vec![("profile-id".to_string(), "1".to_string())]; |
| 2759 | let params = FormatParam::parse_pairs(only_profile); |
| 2760 | assert!( |
| 2761 | !params |
| 2762 | .iter() |
| 2763 | .any(|p| matches!(p, FormatParam::H265ProfileTierLevel(_))) |
| 2764 | ); |
| 2765 | } |
| 2766 | |
| 2767 | /// Test H.265 with Chrome-style profile-only in complete SDP. |
| 2768 | /// Verifies that browser compatibility mode works in real SDP. |
| 2769 | #[test] |
| 2770 | fn h265_chrome_profile_only_sdp() { |
| 2771 | let input = "v=0\r\n\ |
| 2772 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 2773 | s=-\r\n\ |
| 2774 | t=0 0\r\n\ |
| 2775 | a=group:BUNDLE 0\r\n\ |
| 2776 | m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\ |
| 2777 | c=IN IP4 0.0.0.0\r\n\ |
| 2778 | a=mid:0\r\n\ |
| 2779 | a=sendrecv\r\n\ |
| 2780 | a=rtpmap:96 H265/90000\r\n\ |
| 2781 | a=fmtp:96 profile-id=1\r\n\ |
| 2782 | a=setup:actpass\r\n\ |
| 2783 | a=ice-ufrag:test\r\n\ |
| 2784 | a=ice-pwd:testpassword\r\n\ |
| 2785 | "; |
| 2786 | |
| 2787 | let sdp = Sdp::parse(input).expect("should parse"); |
| 2788 | let params = sdp.media_lines[0].rtp_params(); |
| 2789 | |
| 2790 | let h265 = params |
| 2791 | .iter() |
| 2792 | .find(|p| p.spec.codec == Codec::H265) |
| 2793 | .expect("should have H.265"); |
| 2794 | |
| 2795 | // Should NOT have full PTL |
| 2796 | assert!(h265.spec.format.h265_profile_tier_level.is_none()); |
| 2797 | |
| 2798 | // Should have profile_id field instead |
| 2799 | assert_eq!(h265.spec.format.profile_id, Some(1)); |
| 2800 | } |
| 2801 | |
| 2802 | /// Test H.265 different level values in valid range. |
| 2803 | /// Ensures all valid level IDs are properly handled. |
| 2804 | #[test] |
| 2805 | fn h265_all_valid_levels() { |
| 2806 | let valid_levels = vec![ |
| 2807 | 30, // Level 1.0 |
| 2808 | 60, // Level 2.0 |
| 2809 | 63, // Level 2.1 |
| 2810 | 90, // Level 3.0 |
| 2811 | 93, // Level 3.1 |
| 2812 | 120, // Level 4.0 |
| 2813 | 123, // Level 4.1 |
| 2814 | 150, // Level 5.0 |
| 2815 | 153, // Level 5.1 |
| 2816 | 156, // Level 5.2 |
| 2817 | 180, // Level 6.0 |
| 2818 | 183, // Level 6.1 |
| 2819 | 186, // Level 6.2 |
| 2820 | ]; |
| 2821 | |
| 2822 | for level_id in valid_levels { |
| 2823 | let ptl = H265ProfileTierLevel::new(1, 0, level_id); |
| 2824 | assert!(ptl.is_some(), "Level ID {} should be valid", level_id); |
| 2825 | |
| 2826 | let f = FormatParams { |
| 2827 | h265_profile_tier_level: ptl, |
| 2828 | ..Default::default() |
| 2829 | }; |
| 2830 | |
| 2831 | let serialized = f.to_string(); |
| 2832 | assert!(serialized.contains(&format!("level-id={}", level_id))); |
| 2833 | } |
| 2834 | } |
| 2835 | } |
| 2836 | |
| 2837 | /// Core H.266 (VVC) codec-specific tests, mirrored from h265_codec. |
| 2838 | /// Tests basic H.266 parameter handling including profile-tier-level parsing, |
| 2839 | /// serialization, and format parameter combinations. |
| 2840 | mod h266_codec { |
| 2841 | use super::*; |
| 2842 | |
| 2843 | /// Test that H.266 format parameters can be serialized to a string and parsed back |
| 2844 | /// without loss of information. Verifies the round-trip conversion works correctly. |
| 2845 | #[test] |
| 2846 | fn h266_fmtp_round_trip() { |
| 2847 | // Test that H.266 fmtp params round-trip correctly |
| 2848 | let ptl = H266ProfileTierLevel::new(1, 0, 51).unwrap(); // Main, Main tier, Level 3.1 |
| 2849 | |
| 2850 | let f = FormatParams { |
| 2851 | h266_profile_tier_level: Some(ptl), |
| 2852 | ..Default::default() |
| 2853 | }; |
| 2854 | |
| 2855 | // Serialize to string |
| 2856 | let fmtp_str = f.to_string(); |
| 2857 | assert_eq!(fmtp_str, "profile-id=1;tier-flag=0;level-id=51"); |
| 2858 | |
| 2859 | // Parse back |
| 2860 | let parsed = FormatParams::parse_line(&fmtp_str); |
| 2861 | assert_eq!(parsed.h266_profile_tier_level, Some(ptl)); |
| 2862 | } |
| 2863 | |
| 2864 | /// Test different combinations of H.266 profiles, tiers, and levels to ensure |
| 2865 | /// they are all serialized correctly. Covers Main, Main 10, different tiers, and levels. |
| 2866 | #[test] |
| 2867 | fn h266_different_profiles() { |
| 2868 | // Test Main profile (1) |
| 2869 | let main = H266ProfileTierLevel::new(1, 0, 51).unwrap(); |
| 2870 | let f = FormatParams { |
| 2871 | h266_profile_tier_level: Some(main), |
| 2872 | ..Default::default() |
| 2873 | }; |
| 2874 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=51"); |
| 2875 | |
| 2876 | // Test Main 10 profile (2) |
| 2877 | let main10 = H266ProfileTierLevel::new(33, 0, 51).unwrap(); |
| 2878 | let f = FormatParams { |
| 2879 | h266_profile_tier_level: Some(main10), |
| 2880 | ..Default::default() |
| 2881 | }; |
| 2882 | assert_eq!(f.to_string(), "profile-id=33;tier-flag=0;level-id=51"); |
| 2883 | |
| 2884 | // Test different tier (High tier) |
| 2885 | let high_tier = H266ProfileTierLevel::new(1, 1, 51).unwrap(); |
| 2886 | let f = FormatParams { |
| 2887 | h266_profile_tier_level: Some(high_tier), |
| 2888 | ..Default::default() |
| 2889 | }; |
| 2890 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=1;level-id=51"); |
| 2891 | |
| 2892 | // Test different level (Level 5.1 = 153) |
| 2893 | let level_5_1 = H266ProfileTierLevel::new(1, 0, 83).unwrap(); |
| 2894 | let f = FormatParams { |
| 2895 | h266_profile_tier_level: Some(level_5_1), |
| 2896 | ..Default::default() |
| 2897 | }; |
| 2898 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=83"); |
| 2899 | } |
| 2900 | |
| 2901 | /// Test parsing H.266 parameters from key-value pairs as they would appear in SDP. |
| 2902 | /// Verifies that the three separate params (profile-id, tier-flag, level-id) are |
| 2903 | /// correctly combined into a single H266ProfileTierLevel composite. |
| 2904 | #[test] |
| 2905 | fn h266_parse_from_pairs() { |
| 2906 | // Test parsing from key-value pairs (as would come from SDP) |
| 2907 | let pairs = vec![ |
| 2908 | ("profile-id".to_string(), "1".to_string()), |
| 2909 | ("tier-flag".to_string(), "0".to_string()), |
| 2910 | ("level-id".to_string(), "51".to_string()), |
| 2911 | ]; |
| 2912 | |
| 2913 | let params = FormatParam::parse_pairs(pairs); |
| 2914 | |
| 2915 | // Should have exactly one H266ProfileTierLevel param |
| 2916 | let h266_params: Vec<_> = params |
| 2917 | .iter() |
| 2918 | .filter(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 2919 | .collect(); |
| 2920 | assert_eq!(h266_params.len(), 1); |
| 2921 | |
| 2922 | if let FormatParam::H266ProfileTierLevel(ptl) = h266_params[0] { |
| 2923 | assert_eq!(ptl.profile_id(), 1); |
| 2924 | assert_eq!(ptl.tier_flag(), 0); |
| 2925 | assert_eq!(ptl.level_id(), 51); |
| 2926 | } else { |
| 2927 | panic!("Expected H266ProfileTierLevel"); |
| 2928 | } |
| 2929 | } |
| 2930 | |
| 2931 | /// Test that H.266 profile-tier-level params can coexist with other H.266-specific |
| 2932 | /// parameters like sprop-max-don-diff without interfering with each other. |
| 2933 | #[test] |
| 2934 | fn h266_mixed_with_other_params() { |
| 2935 | // Test H.266 params mixed with other format params |
| 2936 | let pairs = vec![ |
| 2937 | ("profile-id".to_string(), "1".to_string()), |
| 2938 | ("tier-flag".to_string(), "0".to_string()), |
| 2939 | ("level-id".to_string(), "51".to_string()), |
| 2940 | ("sprop-max-don-diff".to_string(), "0".to_string()), |
| 2941 | ]; |
| 2942 | |
| 2943 | let params = FormatParam::parse_pairs(pairs); |
| 2944 | |
| 2945 | // Should have H266ProfileTierLevel and SpropMaxDonDiff |
| 2946 | assert!( |
| 2947 | params |
| 2948 | .iter() |
| 2949 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 2950 | ); |
| 2951 | assert!( |
| 2952 | params |
| 2953 | .iter() |
| 2954 | .any(|p| matches!(p, FormatParam::SpropMaxDonDiff(0))) |
| 2955 | ); |
| 2956 | } |
| 2957 | |
| 2958 | /// Test sprop-max-don-diff parsing, display, and round-trip for various values. |
| 2959 | #[test] |
| 2960 | fn sprop_max_don_diff_parse_and_display() { |
| 2961 | // Parse standalone sprop-max-don-diff |
| 2962 | let param = FormatParam::parse("sprop-max-don-diff", "32"); |
| 2963 | assert!(matches!(param, FormatParam::SpropMaxDonDiff(32))); |
| 2964 | |
| 2965 | // Display format |
| 2966 | assert_eq!(param.to_string(), "sprop-max-don-diff=32"); |
| 2967 | |
| 2968 | // Parse value 0 (DONL disabled) |
| 2969 | let param0 = FormatParam::parse("sprop-max-don-diff", "0"); |
| 2970 | assert!(matches!(param0, FormatParam::SpropMaxDonDiff(0))); |
| 2971 | |
| 2972 | // Parse max valid value (RFC 9328 ยง7.2: 0..32767) |
| 2973 | let param_max = FormatParam::parse("sprop-max-don-diff", "32767"); |
| 2974 | assert!(matches!(param_max, FormatParam::SpropMaxDonDiff(32767))); |
| 2975 | |
| 2976 | // Out-of-range value (32768) rejected per RFC 9328 ยง7.2 |
| 2977 | let param_over = FormatParam::parse("sprop-max-don-diff", "32768"); |
| 2978 | assert!(matches!(param_over, FormatParam::Unknown)); |
| 2979 | |
| 2980 | // Invalid value falls back to Unknown |
| 2981 | let param_bad = FormatParam::parse("sprop-max-don-diff", "notanumber"); |
| 2982 | assert!(matches!(param_bad, FormatParam::Unknown)); |
| 2983 | } |
| 2984 | |
| 2985 | /// Test that sprop-max-don-diff > 0 round-trips through H.266 fmtp with PTL params. |
| 2986 | #[test] |
| 2987 | fn h266_sprop_max_don_diff_nonzero_with_ptl() { |
| 2988 | let pairs = vec![ |
| 2989 | ("profile-id".to_string(), "1".to_string()), |
| 2990 | ("tier-flag".to_string(), "0".to_string()), |
| 2991 | ("level-id".to_string(), "51".to_string()), |
| 2992 | ("sprop-max-don-diff".to_string(), "32".to_string()), |
| 2993 | ]; |
| 2994 | |
| 2995 | let params = FormatParam::parse_pairs(pairs); |
| 2996 | |
| 2997 | // Should have both H266ProfileTierLevel and SpropMaxDonDiff(32) |
| 2998 | assert!( |
| 2999 | params |
| 3000 | .iter() |
| 3001 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3002 | ); |
| 3003 | assert!( |
| 3004 | params |
| 3005 | .iter() |
| 3006 | .any(|p| matches!(p, FormatParam::SpropMaxDonDiff(32))) |
| 3007 | ); |
| 3008 | |
| 3009 | // Verify PTL values are correct |
| 3010 | if let Some(FormatParam::H266ProfileTierLevel(ptl)) = params |
| 3011 | .iter() |
| 3012 | .find(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3013 | { |
| 3014 | assert_eq!(ptl.profile_id(), 1); |
| 3015 | assert_eq!(ptl.tier_flag(), 0); |
| 3016 | assert_eq!(ptl.level_id(), 51); |
| 3017 | } |
| 3018 | } |
| 3019 | } |
| 3020 | |
| 3021 | /// Tests ensuring H.266 parameters are not confused with other codecs. |
| 3022 | /// Verifies that H.266's three-parameter format (profile-id, tier-flag, level-id) |
| 3023 | /// is correctly distinguished from H.264's profile-level-id, VP9's profile-id, |
| 3024 | /// and AV1's profile/tier/level-idx parameters. |
| 3025 | mod no_confusion_h266 { |
| 3026 | use super::*; |
| 3027 | |
| 3028 | /// Test that H.264's profile-level-id parameter (single hex value) is not confused |
| 3029 | /// with H.266's three separate parameters. H.264 uses a different format entirely. |
| 3030 | #[test] |
| 3031 | fn h266_no_confusion_with_h264() { |
| 3032 | // H.264 uses profile-level-id (single param) |
| 3033 | let h264_pairs = vec![ |
| 3034 | ("profile-level-id".to_string(), "42e01f".to_string()), |
| 3035 | ("packetization-mode".to_string(), "1".to_string()), |
| 3036 | ]; |
| 3037 | |
| 3038 | let h264_params = FormatParam::parse_pairs(h264_pairs); |
| 3039 | |
| 3040 | // Should NOT have H266ProfileTierLevel |
| 3041 | assert!( |
| 3042 | !h264_params |
| 3043 | .iter() |
| 3044 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3045 | ); |
| 3046 | |
| 3047 | // Should have ProfileLevelId (H.264) |
| 3048 | assert!( |
| 3049 | h264_params |
| 3050 | .iter() |
| 3051 | .any(|p| matches!(p, FormatParam::ProfileLevelId(_))) |
| 3052 | ); |
| 3053 | assert!( |
| 3054 | h264_params |
| 3055 | .iter() |
| 3056 | .any(|p| matches!(p, FormatParam::PacketizationMode(1))) |
| 3057 | ); |
| 3058 | } |
| 3059 | |
| 3060 | /// Test that VP9's profile-id parameter (standalone) is not confused with H.266's |
| 3061 | /// profile-id which must be combined with tier-flag and level-id. |
| 3062 | #[test] |
| 3063 | fn h266_no_confusion_with_vp9() { |
| 3064 | // VP9 uses profile-id (single param, not combined) |
| 3065 | let vp9_pairs = vec![("profile-id".to_string(), "0".to_string())]; |
| 3066 | |
| 3067 | let vp9_params = FormatParam::parse_pairs(vp9_pairs); |
| 3068 | |
| 3069 | // Should NOT have H266ProfileTierLevel (missing tier-flag and level-id) |
| 3070 | assert!( |
| 3071 | !vp9_params |
| 3072 | .iter() |
| 3073 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3074 | ); |
| 3075 | |
| 3076 | // Should have ProfileId (VP9) |
| 3077 | assert!( |
| 3078 | vp9_params |
| 3079 | .iter() |
| 3080 | .any(|p| matches!(p, FormatParam::ProfileId(0))) |
| 3081 | ); |
| 3082 | } |
| 3083 | |
| 3084 | /// Test that AV1's parameters (profile, tier, level-idx) use different naming than |
| 3085 | /// H.266 (profile-id, tier-flag, level-id) and are not confused during parsing. |
| 3086 | #[test] |
| 3087 | fn h266_no_confusion_with_av1() { |
| 3088 | // AV1 uses profile, level-idx, tier (but should not be confused with H.266) |
| 3089 | let av1_pairs = vec![ |
| 3090 | ("profile".to_string(), "0".to_string()), |
| 3091 | ("level-idx".to_string(), "5".to_string()), |
| 3092 | ("tier".to_string(), "0".to_string()), |
| 3093 | ]; |
| 3094 | |
| 3095 | let av1_params = FormatParam::parse_pairs(av1_pairs); |
| 3096 | |
| 3097 | // Should NOT have H266ProfileTierLevel |
| 3098 | // (AV1 uses different param names: profile vs profile-id, |
| 3099 | // tier vs tier-flag, level-idx vs level-id) |
| 3100 | assert!( |
| 3101 | !av1_params |
| 3102 | .iter() |
| 3103 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3104 | ); |
| 3105 | |
| 3106 | // Should have AV1-specific params |
| 3107 | assert!( |
| 3108 | av1_params |
| 3109 | .iter() |
| 3110 | .any(|p| matches!(p, FormatParam::Profile(0))) |
| 3111 | ); |
| 3112 | assert!( |
| 3113 | av1_params |
| 3114 | .iter() |
| 3115 | .any(|p| matches!(p, FormatParam::LevelIdx(5))) |
| 3116 | ); |
| 3117 | assert!(av1_params.iter().any(|p| matches!(p, FormatParam::Tier(0)))); |
| 3118 | } |
| 3119 | } |
| 3120 | |
| 3121 | /// Additional H.266 parsing and validation tests. |
| 3122 | /// Covers incomplete parameter handling and complete SDP integration scenarios. |
| 3123 | mod h266_additional { |
| 3124 | use super::*; |
| 3125 | |
| 3126 | /// Test that incomplete H.266 parameter sets do not create a composite ProfileTierLevel. |
| 3127 | /// All three parameters (profile-id, tier-flag, level-id) must be present. |
| 3128 | #[test] |
| 3129 | fn h266_partial_params_no_composite() { |
| 3130 | // If only some H.266 params are present, should not create composite |
| 3131 | let partial_pairs = vec![ |
| 3132 | ("profile-id".to_string(), "1".to_string()), |
| 3133 | ("tier-flag".to_string(), "0".to_string()), |
| 3134 | // Missing level-id |
| 3135 | ]; |
| 3136 | |
| 3137 | let params = FormatParam::parse_pairs(partial_pairs); |
| 3138 | |
| 3139 | // Should NOT have H266ProfileTierLevel (incomplete) |
| 3140 | assert!( |
| 3141 | !params |
| 3142 | .iter() |
| 3143 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3144 | ); |
| 3145 | } |
| 3146 | |
| 3147 | /// Test H.266 parameters in a complete SDP offer/answer scenario. |
| 3148 | /// Verifies that H.266 rtpmap and fmtp lines are correctly parsed along with RTX. |
| 3149 | #[test] |
| 3150 | fn h266_in_complete_sdp() { |
| 3151 | let input = "v=0\r\n\ |
| 3152 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 3153 | s=-\r\n\ |
| 3154 | t=0 0\r\n\ |
| 3155 | a=group:BUNDLE 0\r\n\ |
| 3156 | m=video 9 UDP/TLS/RTP/SAVPF 96 97\r\n\ |
| 3157 | c=IN IP4 0.0.0.0\r\n\ |
| 3158 | a=mid:0\r\n\ |
| 3159 | a=sendrecv\r\n\ |
| 3160 | a=rtpmap:96 H266/90000\r\n\ |
| 3161 | a=fmtp:96 profile-id=1;tier-flag=0;level-id=51\r\n\ |
| 3162 | a=rtpmap:97 rtx/90000\r\n\ |
| 3163 | a=fmtp:97 apt=96\r\n\ |
| 3164 | a=setup:actpass\r\n\ |
| 3165 | a=ice-ufrag:test\r\n\ |
| 3166 | a=ice-pwd:testpassword\r\n\ |
| 3167 | "; |
| 3168 | |
| 3169 | let sdp = Sdp::parse(input).expect("should parse"); |
| 3170 | |
| 3171 | // Get RTP params |
| 3172 | let params = sdp.media_lines[0].rtp_params(); |
| 3173 | |
| 3174 | // Find H.266 payload |
| 3175 | let h266_payload = params |
| 3176 | .iter() |
| 3177 | .find(|p| p.spec.codec == Codec::H266) |
| 3178 | .expect("should have H.266"); |
| 3179 | |
| 3180 | assert_eq!(h266_payload.pt, 96.into()); |
| 3181 | assert_eq!(h266_payload.spec.clock_rate, Frequency::NINETY_KHZ); |
| 3182 | |
| 3183 | // Check H.266 params |
| 3184 | let ptl = h266_payload |
| 3185 | .spec |
| 3186 | .format |
| 3187 | .h266_profile_tier_level |
| 3188 | .expect("should have H.266 PTL"); |
| 3189 | assert_eq!(ptl.profile_id(), 1); |
| 3190 | assert_eq!(ptl.tier_flag(), 0); |
| 3191 | assert_eq!(ptl.level_id(), 51); |
| 3192 | |
| 3193 | // Check RTX |
| 3194 | assert_eq!(h266_payload.resend, Some(97.into())); |
| 3195 | } |
| 3196 | } |
| 3197 | |
| 3198 | /// Multi-codec integration tests. |
| 3199 | /// Verifies that multiple video codecs (H.264, H.266, VP8, VP9) can coexist |
| 3200 | /// in the same SDP without parameter conflicts or interference. |
| 3201 | mod multi_codec_h266 { |
| 3202 | use super::*; |
| 3203 | |
| 3204 | /// Test that H.264, H.266, and other video codecs can coexist in the same SDP |
| 3205 | /// without their format parameters interfering with each other. |
| 3206 | #[test] |
| 3207 | fn h266_and_h264_coexist() { |
| 3208 | let input = "v=0\r\n\ |
| 3209 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 3210 | s=-\r\n\ |
| 3211 | t=0 0\r\n\ |
| 3212 | a=group:BUNDLE 0\r\n\ |
| 3213 | m=video 9 UDP/TLS/RTP/SAVPF 96 97 98\r\n\ |
| 3214 | c=IN IP4 0.0.0.0\r\n\ |
| 3215 | a=mid:0\r\n\ |
| 3216 | a=sendrecv\r\n\ |
| 3217 | a=rtpmap:96 H264/90000\r\n\ |
| 3218 | a=fmtp:96 profile-level-id=42e01f;packetization-mode=1\r\n\ |
| 3219 | a=rtpmap:97 H266/90000\r\n\ |
| 3220 | a=fmtp:97 profile-id=1;tier-flag=0;level-id=51\r\n\ |
| 3221 | a=rtpmap:98 VP8/90000\r\n\ |
| 3222 | a=setup:actpass\r\n\ |
| 3223 | a=ice-ufrag:test\r\n\ |
| 3224 | a=ice-pwd:testpassword\r\n\ |
| 3225 | "; |
| 3226 | |
| 3227 | let sdp = Sdp::parse(input).expect("should parse"); |
| 3228 | let params = sdp.media_lines[0].rtp_params(); |
| 3229 | |
| 3230 | // Check H.264 |
| 3231 | let h264 = params |
| 3232 | .iter() |
| 3233 | .find(|p| p.spec.codec == Codec::H264) |
| 3234 | .expect("should have H.264"); |
| 3235 | assert_eq!(h264.pt, 96.into()); |
| 3236 | assert_eq!(h264.spec.format.profile_level_id, Some(0x42e01f)); |
| 3237 | assert_eq!(h264.spec.format.packetization_mode, Some(1)); |
| 3238 | assert!(h264.spec.format.h266_profile_tier_level.is_none()); |
| 3239 | |
| 3240 | // Check H.266 |
| 3241 | let h266 = params |
| 3242 | .iter() |
| 3243 | .find(|p| p.spec.codec == Codec::H266) |
| 3244 | .expect("should have H.266"); |
| 3245 | assert_eq!(h266.pt, 97.into()); |
| 3246 | let ptl = h266 |
| 3247 | .spec |
| 3248 | .format |
| 3249 | .h266_profile_tier_level |
| 3250 | .expect("should have H.266 PTL"); |
| 3251 | assert_eq!(ptl.profile_id(), 1); |
| 3252 | assert!(h266.spec.format.profile_level_id.is_none()); |
| 3253 | |
| 3254 | // Check VP8 |
| 3255 | let vp8 = params |
| 3256 | .iter() |
| 3257 | .find(|p| p.spec.codec == Codec::Vp8) |
| 3258 | .expect("should have VP8"); |
| 3259 | assert_eq!(vp8.pt, 98.into()); |
| 3260 | assert!(vp8.spec.format.h266_profile_tier_level.is_none()); |
| 3261 | assert!(vp8.spec.format.profile_level_id.is_none()); |
| 3262 | } |
| 3263 | } |
| 3264 | |
| 3265 | /// H.266 serialization and edge case tests. |
| 3266 | /// Tests media attribute generation, empty parameter handling, extreme values, |
| 3267 | /// parameter ordering independence, DONL parameters, and invalid value handling. |
| 3268 | mod h266_serialization { |
| 3269 | use super::*; |
| 3270 | |
| 3271 | /// Test that H.266 parameters are correctly serialized as media attributes, |
| 3272 | /// including rtpmap, fmtp with ProfileTierLevel, rtcp-fb, and RTX payload. |
| 3273 | #[test] |
| 3274 | fn h266_serialization_in_media_attrs() { |
| 3275 | let ptl = H266ProfileTierLevel::new(33, 1, 83).unwrap(); // Main 10, High tier, Level 5.1 |
| 3276 | |
| 3277 | let mut payload = PayloadParams::new( |
| 3278 | 100.into(), |
| 3279 | Some(101.into()), |
| 3280 | CodecSpec { |
| 3281 | codec: Codec::H266, |
| 3282 | clock_rate: Frequency::NINETY_KHZ, |
| 3283 | channels: None, |
| 3284 | format: FormatParams { |
| 3285 | h266_profile_tier_level: Some(ptl), |
| 3286 | ..Default::default() |
| 3287 | }, |
| 3288 | }, |
| 3289 | ); |
| 3290 | payload.fb_transport_cc = true; |
| 3291 | payload.fb_nack = true; |
| 3292 | payload.fb_pli = true; |
| 3293 | |
| 3294 | let mut attrs = vec![]; |
| 3295 | payload.as_media_attrs(&mut attrs); |
| 3296 | |
| 3297 | // Should have rtpmap for H.266 |
| 3298 | assert!(attrs.iter().any(|a| matches!(a, |
| 3299 | MediaAttribute::RtpMap { pt, value } |
| 3300 | if *pt == 100.into() && value.codec == Codec::H266 |
| 3301 | ))); |
| 3302 | |
| 3303 | // Should have fmtp with H.266 params |
| 3304 | let fmtp = attrs.iter().find(|a| { |
| 3305 | matches!(a, |
| 3306 | MediaAttribute::Fmtp { pt, .. } if *pt == 100.into() |
| 3307 | ) |
| 3308 | }); |
| 3309 | assert!(fmtp.is_some()); |
| 3310 | |
| 3311 | if let Some(MediaAttribute::Fmtp { values, .. }) = fmtp { |
| 3312 | assert!( |
| 3313 | values |
| 3314 | .iter() |
| 3315 | .any(|v| matches!(v, FormatParam::H266ProfileTierLevel(p) |
| 3316 | if p.profile_id() == 33 && p.tier_flag() == 1 && p.level_id() == 83 |
| 3317 | )) |
| 3318 | ); |
| 3319 | } |
| 3320 | |
| 3321 | // Should have rtx mapping |
| 3322 | assert!(attrs.iter().any(|a| matches!(a, |
| 3323 | MediaAttribute::RtpMap { pt, value } |
| 3324 | if *pt == 101.into() && value.codec == Codec::Rtx |
| 3325 | ))); |
| 3326 | } |
| 3327 | |
| 3328 | /// Test that empty format parameters do not generate an a=fmtp line in SDP, |
| 3329 | /// avoiding invalid SDP syntax with empty fmtp attributes. |
| 3330 | #[test] |
| 3331 | fn h266_empty_fmtp_not_serialized() { |
| 3332 | // Empty fmtp should not be written |
| 3333 | let payload = PayloadParams::new( |
| 3334 | 100.into(), |
| 3335 | None, |
| 3336 | CodecSpec { |
| 3337 | codec: Codec::H266, |
| 3338 | clock_rate: Frequency::NINETY_KHZ, |
| 3339 | channels: None, |
| 3340 | format: FormatParams::default(), |
| 3341 | }, |
| 3342 | ); |
| 3343 | |
| 3344 | let mut attrs = vec![]; |
| 3345 | payload.as_media_attrs(&mut attrs); |
| 3346 | |
| 3347 | // Should NOT have fmtp line (it would be empty) |
| 3348 | assert!( |
| 3349 | !attrs |
| 3350 | .iter() |
| 3351 | .any(|a| matches!(a, MediaAttribute::Fmtp { .. })) |
| 3352 | ); |
| 3353 | } |
| 3354 | |
| 3355 | /// Test H.266 with extreme profile/tier/level values at boundaries. |
| 3356 | /// Verifies parsing and serialization of edge case values. |
| 3357 | #[test] |
| 3358 | fn h266_extreme_values() { |
| 3359 | // Test minimum level (Level 1.0 = 30) |
| 3360 | let min_level = H266ProfileTierLevel::new(1, 0, 16).unwrap(); |
| 3361 | let f = FormatParams { |
| 3362 | h266_profile_tier_level: Some(min_level), |
| 3363 | ..Default::default() |
| 3364 | }; |
| 3365 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=16"); |
| 3366 | |
| 3367 | // Test maximum level (Level 6.2 = 186) |
| 3368 | let max_level = H266ProfileTierLevel::new(1, 0, 102).unwrap(); |
| 3369 | let f = FormatParams { |
| 3370 | h266_profile_tier_level: Some(max_level), |
| 3371 | ..Default::default() |
| 3372 | }; |
| 3373 | assert_eq!(f.to_string(), "profile-id=1;tier-flag=0;level-id=102"); |
| 3374 | |
| 3375 | // Test Main10 with High tier |
| 3376 | let main10_high = H266ProfileTierLevel::new(33, 1, 51).unwrap(); |
| 3377 | let f = FormatParams { |
| 3378 | h266_profile_tier_level: Some(main10_high), |
| 3379 | ..Default::default() |
| 3380 | }; |
| 3381 | assert_eq!(f.to_string(), "profile-id=33;tier-flag=1;level-id=51"); |
| 3382 | } |
| 3383 | |
| 3384 | /// Test H.266 parameter ordering doesn't affect parsing. |
| 3385 | /// SDP parameters can appear in any order. |
| 3386 | #[test] |
| 3387 | fn h266_parameter_order_independence() { |
| 3388 | // Normal order |
| 3389 | let normal_order = vec![ |
| 3390 | ("profile-id".to_string(), "1".to_string()), |
| 3391 | ("tier-flag".to_string(), "0".to_string()), |
| 3392 | ("level-id".to_string(), "51".to_string()), |
| 3393 | ]; |
| 3394 | |
| 3395 | // Reversed order |
| 3396 | let reversed_order = vec![ |
| 3397 | ("level-id".to_string(), "51".to_string()), |
| 3398 | ("tier-flag".to_string(), "0".to_string()), |
| 3399 | ("profile-id".to_string(), "1".to_string()), |
| 3400 | ]; |
| 3401 | |
| 3402 | // Mixed order |
| 3403 | let mixed_order = vec![ |
| 3404 | ("tier-flag".to_string(), "0".to_string()), |
| 3405 | ("profile-id".to_string(), "1".to_string()), |
| 3406 | ("level-id".to_string(), "51".to_string()), |
| 3407 | ]; |
| 3408 | |
| 3409 | let normal_params = FormatParam::parse_pairs(normal_order); |
| 3410 | let reversed_params = FormatParam::parse_pairs(reversed_order); |
| 3411 | let mixed_params = FormatParam::parse_pairs(mixed_order); |
| 3412 | |
| 3413 | // All should create H266ProfileTierLevel |
| 3414 | assert!( |
| 3415 | normal_params |
| 3416 | .iter() |
| 3417 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3418 | ); |
| 3419 | assert!( |
| 3420 | reversed_params |
| 3421 | .iter() |
| 3422 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3423 | ); |
| 3424 | assert!( |
| 3425 | mixed_params |
| 3426 | .iter() |
| 3427 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3428 | ); |
| 3429 | |
| 3430 | // All should have the same values |
| 3431 | for params in [&normal_params, &reversed_params, &mixed_params] { |
| 3432 | if let Some(FormatParam::H266ProfileTierLevel(ptl)) = params |
| 3433 | .iter() |
| 3434 | .find(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3435 | { |
| 3436 | assert_eq!(ptl.profile_id(), 1); |
| 3437 | assert_eq!(ptl.tier_flag(), 0); |
| 3438 | assert_eq!(ptl.level_id(), 51); |
| 3439 | } |
| 3440 | } |
| 3441 | } |
| 3442 | |
| 3443 | #[test] |
| 3444 | fn h266_invalid_parameter_values() { |
| 3445 | // Invalid profile-id |
| 3446 | let invalid_profile = vec![ |
| 3447 | ("profile-id".to_string(), "999".to_string()), |
| 3448 | ("tier-flag".to_string(), "0".to_string()), |
| 3449 | ("level-id".to_string(), "51".to_string()), |
| 3450 | ]; |
| 3451 | let params = FormatParam::parse_pairs(invalid_profile); |
| 3452 | assert!( |
| 3453 | !params |
| 3454 | .iter() |
| 3455 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3456 | ); |
| 3457 | |
| 3458 | // Invalid tier-flag |
| 3459 | let invalid_tier = vec![ |
| 3460 | ("profile-id".to_string(), "1".to_string()), |
| 3461 | ("tier-flag".to_string(), "5".to_string()), |
| 3462 | ("level-id".to_string(), "51".to_string()), |
| 3463 | ]; |
| 3464 | let params = FormatParam::parse_pairs(invalid_tier); |
| 3465 | assert!( |
| 3466 | !params |
| 3467 | .iter() |
| 3468 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3469 | ); |
| 3470 | |
| 3471 | // Invalid level-id |
| 3472 | let invalid_level = vec![ |
| 3473 | ("profile-id".to_string(), "1".to_string()), |
| 3474 | ("tier-flag".to_string(), "0".to_string()), |
| 3475 | ("level-id".to_string(), "999".to_string()), |
| 3476 | ]; |
| 3477 | let params = FormatParam::parse_pairs(invalid_level); |
| 3478 | assert!( |
| 3479 | !params |
| 3480 | .iter() |
| 3481 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3482 | ); |
| 3483 | } |
| 3484 | } |
| 3485 | |
| 3486 | /// H.266 integration with RTCP feedback mechanisms. |
| 3487 | /// Tests H.266 behavior with multiple codecs and various RTCP feedback types |
| 3488 | /// including NACK, PLI, FIR, and transport-cc. |
| 3489 | mod h266_integration { |
| 3490 | use super::*; |
| 3491 | |
| 3492 | /// Test H.266 in SDP with multiple video codecs and feedback mechanisms. |
| 3493 | /// Verifies proper parsing of complex multi-codec scenarios. |
| 3494 | #[test] |
| 3495 | fn h266_multi_codec_with_feedback() { |
| 3496 | let input = "v=0\r\n\ |
| 3497 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 3498 | s=-\r\n\ |
| 3499 | t=0 0\r\n\ |
| 3500 | a=group:BUNDLE 0\r\n\ |
| 3501 | m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99\r\n\ |
| 3502 | c=IN IP4 0.0.0.0\r\n\ |
| 3503 | a=mid:0\r\n\ |
| 3504 | a=sendrecv\r\n\ |
| 3505 | a=rtpmap:96 H266/90000\r\n\ |
| 3506 | a=fmtp:96 profile-id=1;tier-flag=0;level-id=51\r\n\ |
| 3507 | a=rtcp-fb:96 nack\r\n\ |
| 3508 | a=rtcp-fb:96 nack pli\r\n\ |
| 3509 | a=rtcp-fb:96 ccm fir\r\n\ |
| 3510 | a=rtcp-fb:96 transport-cc\r\n\ |
| 3511 | a=rtpmap:97 rtx/90000\r\n\ |
| 3512 | a=fmtp:97 apt=96\r\n\ |
| 3513 | a=rtpmap:98 VP9/90000\r\n\ |
| 3514 | a=fmtp:98 profile-id=0\r\n\ |
| 3515 | a=rtpmap:99 rtx/90000\r\n\ |
| 3516 | a=fmtp:99 apt=98\r\n\ |
| 3517 | a=setup:actpass\r\n\ |
| 3518 | a=ice-ufrag:test\r\n\ |
| 3519 | a=ice-pwd:testpassword\r\n\ |
| 3520 | "; |
| 3521 | |
| 3522 | let sdp = Sdp::parse(input).expect("should parse"); |
| 3523 | let params = sdp.media_lines[0].rtp_params(); |
| 3524 | |
| 3525 | // Check H.266 with all feedback mechanisms |
| 3526 | let h266 = params |
| 3527 | .iter() |
| 3528 | .find(|p| p.spec.codec == Codec::H266) |
| 3529 | .expect("should have H.266"); |
| 3530 | assert_eq!(h266.pt, 96.into()); |
| 3531 | assert!(h266.fb_nack); |
| 3532 | assert!(h266.fb_pli); |
| 3533 | assert!(h266.fb_fir); |
| 3534 | assert!(h266.fb_transport_cc); |
| 3535 | assert_eq!(h266.resend, Some(97.into())); |
| 3536 | |
| 3537 | // Verify H.266 PTL is correct |
| 3538 | let ptl = h266 |
| 3539 | .spec |
| 3540 | .format |
| 3541 | .h266_profile_tier_level |
| 3542 | .expect("should have PTL"); |
| 3543 | assert_eq!(ptl.profile_id(), 1); |
| 3544 | |
| 3545 | // Check VP9 is separate and correct |
| 3546 | let vp9 = params |
| 3547 | .iter() |
| 3548 | .find(|p| p.spec.codec == Codec::Vp9) |
| 3549 | .expect("should have VP9"); |
| 3550 | assert_eq!(vp9.pt, 98.into()); |
| 3551 | assert_eq!(vp9.spec.format.profile_id, Some(0)); |
| 3552 | assert!(vp9.spec.format.h266_profile_tier_level.is_none()); |
| 3553 | } |
| 3554 | } |
| 3555 | |
| 3556 | /// Advanced H.266 functionality tests. |
| 3557 | /// Covers SDP round-trip serialization, incomplete parameter permutations, |
| 3558 | /// browser compatibility (Chrome profile-only mode), and validation of all |
| 3559 | /// standard H.266 level values (1.0 through 6.2). |
| 3560 | mod h266_advanced { |
| 3561 | use super::*; |
| 3562 | |
| 3563 | /// Test H.266 SDP serialization maintains parameter integrity. |
| 3564 | /// Ensures that serialized SDP can be parsed back with identical values. |
| 3565 | #[test] |
| 3566 | fn h266_sdp_serialization_round_trip() { |
| 3567 | let ptl = H266ProfileTierLevel::new(33, 0, 83).unwrap(); |
| 3568 | |
| 3569 | let payload = PayloadParams::new( |
| 3570 | 100.into(), |
| 3571 | Some(101.into()), |
| 3572 | CodecSpec { |
| 3573 | codec: Codec::H266, |
| 3574 | clock_rate: Frequency::NINETY_KHZ, |
| 3575 | channels: None, |
| 3576 | format: FormatParams { |
| 3577 | h266_profile_tier_level: Some(ptl), |
| 3578 | ..Default::default() |
| 3579 | }, |
| 3580 | }, |
| 3581 | ); |
| 3582 | |
| 3583 | let mut attrs = vec![]; |
| 3584 | payload.as_media_attrs(&mut attrs); |
| 3585 | |
| 3586 | // Find the fmtp line |
| 3587 | let fmtp = attrs |
| 3588 | .iter() |
| 3589 | .find_map(|a| match a { |
| 3590 | MediaAttribute::Fmtp { pt, values } if *pt == 100.into() => Some(values), |
| 3591 | _ => None, |
| 3592 | }) |
| 3593 | .expect("should have fmtp"); |
| 3594 | |
| 3595 | // Should contain H266ProfileTierLevel |
| 3596 | assert!(fmtp.iter().any( |
| 3597 | |v| matches!(v, FormatParam::H266ProfileTierLevel(p) if p.profile_id() == 33) |
| 3598 | )); |
| 3599 | } |
| 3600 | |
| 3601 | /// Test that only two of three H.266 params doesn't create PTL. |
| 3602 | /// Each permutation of missing parameter should fail. |
| 3603 | #[test] |
| 3604 | fn h266_incomplete_params_all_permutations() { |
| 3605 | // Missing level-id |
| 3606 | let missing_level = vec![ |
| 3607 | ("profile-id".to_string(), "1".to_string()), |
| 3608 | ("tier-flag".to_string(), "0".to_string()), |
| 3609 | ]; |
| 3610 | let params = FormatParam::parse_pairs(missing_level); |
| 3611 | assert!( |
| 3612 | !params |
| 3613 | .iter() |
| 3614 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3615 | ); |
| 3616 | |
| 3617 | // Missing tier-flag |
| 3618 | let missing_tier = vec![ |
| 3619 | ("profile-id".to_string(), "1".to_string()), |
| 3620 | ("level-id".to_string(), "51".to_string()), |
| 3621 | ]; |
| 3622 | let params = FormatParam::parse_pairs(missing_tier); |
| 3623 | assert!( |
| 3624 | !params |
| 3625 | .iter() |
| 3626 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3627 | ); |
| 3628 | |
| 3629 | // Missing profile-id |
| 3630 | let missing_profile = vec![ |
| 3631 | ("tier-flag".to_string(), "0".to_string()), |
| 3632 | ("level-id".to_string(), "51".to_string()), |
| 3633 | ]; |
| 3634 | let params = FormatParam::parse_pairs(missing_profile); |
| 3635 | assert!( |
| 3636 | !params |
| 3637 | .iter() |
| 3638 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3639 | ); |
| 3640 | |
| 3641 | // Only one parameter |
| 3642 | let only_profile = vec![("profile-id".to_string(), "1".to_string())]; |
| 3643 | let params = FormatParam::parse_pairs(only_profile); |
| 3644 | assert!( |
| 3645 | !params |
| 3646 | .iter() |
| 3647 | .any(|p| matches!(p, FormatParam::H266ProfileTierLevel(_))) |
| 3648 | ); |
| 3649 | } |
| 3650 | |
| 3651 | /// Test H.266 with Chrome-style profile-only in complete SDP. |
| 3652 | /// Verifies that browser compatibility mode works in real SDP. |
| 3653 | #[test] |
| 3654 | fn h266_chrome_profile_only_sdp() { |
| 3655 | let input = "v=0\r\n\ |
| 3656 | o=- 123456 2 IN IP4 127.0.0.1\r\n\ |
| 3657 | s=-\r\n\ |
| 3658 | t=0 0\r\n\ |
| 3659 | a=group:BUNDLE 0\r\n\ |
| 3660 | m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\ |
| 3661 | c=IN IP4 0.0.0.0\r\n\ |
| 3662 | a=mid:0\r\n\ |
| 3663 | a=sendrecv\r\n\ |
| 3664 | a=rtpmap:96 H266/90000\r\n\ |
| 3665 | a=fmtp:96 profile-id=1\r\n\ |
| 3666 | a=setup:actpass\r\n\ |
| 3667 | a=ice-ufrag:test\r\n\ |
| 3668 | a=ice-pwd:testpassword\r\n\ |
| 3669 | "; |
| 3670 | |
| 3671 | let sdp = Sdp::parse(input).expect("should parse"); |
| 3672 | let params = sdp.media_lines[0].rtp_params(); |
| 3673 | |
| 3674 | let h266 = params |
| 3675 | .iter() |
| 3676 | .find(|p| p.spec.codec == Codec::H266) |
| 3677 | .expect("should have H.266"); |
| 3678 | |
| 3679 | // Should NOT have full PTL |
| 3680 | assert!(h266.spec.format.h266_profile_tier_level.is_none()); |
| 3681 | |
| 3682 | // Should have profile_id field instead |
| 3683 | assert_eq!(h266.spec.format.profile_id, Some(1)); |
| 3684 | } |
| 3685 | |
| 3686 | /// Test H.266 different level values in valid range. |
| 3687 | /// Ensures all valid level IDs are properly handled. |
| 3688 | #[test] |
| 3689 | fn h266_all_valid_levels() { |
| 3690 | let valid_levels = vec![ |
| 3691 | 16, // Level 1.0 |
| 3692 | 32, // Level 2.0 |
| 3693 | 35, // Level 2.1 |
| 3694 | 48, // Level 3.0 |
| 3695 | 51, // Level 3.1 |
| 3696 | 64, // Level 4.0 |
| 3697 | 67, // Level 4.1 |
| 3698 | 80, // Level 5.0 |
| 3699 | 83, // Level 5.1 |
| 3700 | 86, // Level 5.2 |
| 3701 | 96, // Level 6.0 |
| 3702 | 99, // Level 6.1 |
| 3703 | 102, // Level 6.2 |
| 3704 | ]; |
| 3705 | |
| 3706 | for level_id in valid_levels { |
| 3707 | let ptl = H266ProfileTierLevel::new(1, 0, level_id); |
| 3708 | assert!(ptl.is_some(), "Level ID {} should be valid", level_id); |
| 3709 | |
| 3710 | let f = FormatParams { |
| 3711 | h266_profile_tier_level: ptl, |
| 3712 | ..Default::default() |
| 3713 | }; |
| 3714 | |
| 3715 | let serialized = f.to_string(); |
| 3716 | assert!(serialized.contains(&format!("level-id={}", level_id))); |
| 3717 | } |
| 3718 | } |
| 3719 | } |
| 3720 | } |