Skip to content
File

Blob: firmware/vendor/str0m/src/format/payload_params.rs

rust1883 lines
1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3use std::ops::RangeInclusive;
4 
5use crate::packet::H264ProfileLevel;
6use crate::packet::H265ProfileTierLevel;
7use crate::packet::H266ProfileTierLevel;
8use crate::rtp_::Pt;
9 
10use super::codec::{Codec, CodecSpec};
11 
12/// Preferred ranges for dynamic payload type allocation.
13pub(crate) const PREFERED_RANGES: &[RangeInclusive<usize>] = &[
14 // Payload identifiers 96–127 are used for payloads defined dynamically during a session.
15 96..=127,
16 // "unassigned" ranged. note that RTCP packet type 207 (XR, Extended Reports) would be
17 // indistinguishable from RTP payload types 79 with the marker bit set
18 80..=95,
19 77..=78,
20 // reserved because RTCP packet types 200–204 would otherwise be indistinguishable
21 // from RTP payload types 72–76
22 // 72..77,
23 // lol range.
24 35..=71,
25];
26 
27pub(crate) trait Claimed {
28 fn assert_claim_once(&mut self, pt: Pt);
29 fn is_claimed(&self, pt: Pt) -> bool;
30 fn find_unclaimed(
31 &self,
32 ranges: &[RangeInclusive<usize>],
33 unlocked: &HashSet<Pt>,
34 ) -> Option<Pt>;
35}
36 
37impl Claimed for [bool; 128] {
38 fn assert_claim_once(&mut self, pt: Pt) {
39 let idx = *pt as usize;
40 assert!(!self[idx], "Pt locked multiple times: {}", pt);
41 self[idx] = true;
42 }
43 fn is_claimed(&self, pt: Pt) -> bool {
44 let idx = *pt as usize;
45 self[idx]
46 }
47 fn find_unclaimed(
48 &self,
49 ranges: &[RangeInclusive<usize>],
50 unlocked: &HashSet<Pt>,
51 ) -> Option<Pt> {
52 for range in ranges {
53 for i in range.clone() {
54 if !self[i] && !unlocked.contains(&(i as u8).into()) {
55 let pt: Pt = (i as u8).into();
56 return Some(pt);
57 }
58 }
59 }
60 
61 // Failed to find unclaimed PT.
62 None
63 }
64}
65 
66/// Group of parameters for a payload type (PT).
67///
68/// In the SDP a payload type has a number of associated parameters. See example below:
69///
70/// ```text
71/// a=rtpmap:96 H264/90000
72/// a=rtcp-fb:96 goog-remb
73/// a=rtcp-fb:96 transport-cc
74/// a=rtcp-fb:96 ccm fir
75/// a=rtcp-fb:96 nack
76/// a=rtcp-fb:96 nack pli
77/// a=fmtp:96 level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f
78/// ```
79#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
80pub struct PayloadParams {
81 /// The payload type that groups these parameters.
82 pub(crate) pt: Pt,
83 
84 /// Whether these parameters are repairing some other set of parameters.
85 /// This is used to, via PT, separate RTX resend streams from the main stream.
86 pub(crate) resend: Option<Pt>,
87 
88 /// The codec with settings for this group of parameters.
89 pub(crate) spec: CodecSpec,
90 
91 /// Whether the payload use the TWCC feedback mechanic.
92 pub(crate) fb_transport_cc: bool,
93 
94 /// Whether the payload uses NACK to request resends.
95 pub(crate) fb_nack: bool,
96 
97 /// Whether the payload uses the PLI (Picture Loss Indication) mechanic.
98 pub(crate) fb_pli: bool,
99 
100 /// Whether the payload uses the FIR (Full Intra Request) mechanic.
101 pub(crate) fb_fir: bool,
102 
103 /// Whether the payload uses the REMB (Receiver Estimated Maximum Bitrate) mechanic.
104 pub(crate) fb_remb: bool,
105 
106 /// Whether the payload is locked by negotiation or can still be debated.
107 ///
108 /// If we make an OFFER or ANSWER and the direction is sendrecv/recvonly, the parameters are locked
109 /// can't be further changed. If we make an OFFER for a sendonly, the parameters are only proposed
110 /// and don't lock.
111 pub(crate) locked: bool,
112}
113 
114// we don't want to compare "locked"
115impl PartialEq for PayloadParams {
116 fn eq(&self, other: &Self) -> bool {
117 self.pt == other.pt
118 && self.resend == other.resend
119 && self.spec == other.spec
120 && self.fb_transport_cc == other.fb_transport_cc
121 && self.fb_nack == other.fb_nack
122 && self.fb_pli == other.fb_pli
123 && self.fb_fir == other.fb_fir
124 && self.fb_remb == other.fb_remb
125 }
126}
127 
128impl Eq for PayloadParams {}
129 
130impl PayloadParams {
131 /// Maximum match score.
132 ///
133 /// This is returned for an exact `CodecSpec` match in `match_score()`. It is also used as the
134 /// starting point for codec-specific scoring in this file:
135 /// - Opus/H.264/H.265 decrement from a perfect match.
136 /// - VP9/AV1 return the max score when their relevant fmtp parameters match.
137 /// - VP8 currently only matches via the exact `CodecSpec` equality fast-path (no fuzzy scorer).
138 const EXACT_MATCH_SCORE: usize = 100;
139 
140 /// Score used for H.265 profile-only compatibility matches.
141 ///
142 /// Some endpoints (notably browsers) may signal H.265 using only `profile-id` without
143 /// tier/level information; when we can only validate profile equality we return this lower
144 /// score so full ProfileTierLevel matches win when available.
145 const PROFILE_ONLY_MATCH_SCORE: usize = 90;
146 
147 /// Creates new payload params.
148 ///
149 /// * `pt` is the payload type RTP mapping in the session.
150 /// * `resend` is the payload type used for (RTX) resend channel.
151 /// * `spec` configures details about the codec.
152 pub fn new(pt: Pt, resend: Option<Pt>, spec: CodecSpec) -> Self {
153 let is_video = spec.codec.is_video();
154 
155 PayloadParams {
156 pt,
157 resend,
158 
159 spec,
160 
161 // Both audio and video use TWCC
162 fb_transport_cc: true,
163 
164 // Only true for video.
165 fb_fir: is_video,
166 fb_nack: is_video,
167 fb_pli: is_video,
168 fb_remb: is_video,
169 
170 locked: false,
171 }
172 }
173 
174 /// Creates minimal payload params for SSRC 0 BWE probes.
175 ///
176 /// These probes don't carry real media, only padding for bandwidth estimation.
177 /// Uses 90kHz clock rate (video rate) and enables only transport_cc feedback.
178 pub(crate) fn new_probe(pt: Pt) -> Self {
179 use super::codec::Codec;
180 use super::format_params::FormatParams;
181 use crate::rtp_::Frequency;
182 
183 PayloadParams {
184 pt,
185 resend: None,
186 spec: CodecSpec {
187 codec: Codec::Null,
188 clock_rate: Frequency::NINETY_KHZ,
189 channels: None,
190 format: FormatParams::default(),
191 },
192 fb_transport_cc: true,
193 fb_nack: false,
194 fb_pli: false,
195 fb_fir: false,
196 fb_remb: false,
197 locked: false,
198 }
199 }
200 
201 /// The payload type that groups these parameters.
202 pub fn pt(&self) -> Pt {
203 self.pt
204 }
205 
206 /// Whether these parameters are repairing some other set of parameters.
207 /// This is used to, via PT, separate RTX resend streams from the main stream.
208 pub fn resend(&self) -> Option<Pt> {
209 self.resend
210 }
211 
212 /// The codec with settings for this group of parameters.
213 pub fn spec(&self) -> CodecSpec {
214 self.spec
215 }
216 
217 /// Sets whether the payload use the TWCC feedback mechanic.
218 pub fn set_fb_transport_cc(&mut self, fb_transport_cc: bool) {
219 self.fb_transport_cc = fb_transport_cc
220 }
221 
222 /// Whether the payload use the TWCC feedback mechanic.
223 pub fn fb_transport_cc(&self) -> bool {
224 self.fb_transport_cc
225 }
226 
227 /// Sets whether the payload uses NACK to request resends.
228 pub fn set_fb_nack(&mut self, fb_nack: bool) {
229 self.fb_nack = fb_nack
230 }
231 
232 /// Whether the payload uses NACK to request resends.
233 pub fn fb_nack(&self) -> bool {
234 self.fb_nack
235 }
236 
237 /// Set whether the payload uses the PLI (Picture Loss Indication) mechanic.
238 pub fn set_fb_pli(&mut self, fb_pli: bool) {
239 self.fb_pli = fb_pli
240 }
241 
242 /// Whether the payload uses the PLI (Picture Loss Indication) mechanic.
243 pub fn fb_pli(&self) -> bool {
244 self.fb_pli
245 }
246 
247 /// Set whether the payload uses the FIR (Full Intra Request) mechanic.
248 pub fn set_fb_fir(&mut self, fb_fir: bool) {
249 self.fb_fir = fb_fir
250 }
251 
252 /// Whether the payload uses the FIR (Full Intra Request) mechanic.
253 pub fn fb_fir(&self) -> bool {
254 self.fb_fir
255 }
256 
257 /// Set whether the payload uses the REMB (Receiver Estimated Maximum Bitrate) mechanic.
258 pub fn set_fb_remb(&mut self, fb_remb: bool) {
259 self.fb_remb = fb_remb
260 }
261 
262 /// Whether the payload uses the REMB (Receiver Estimated Maximum Bitrate) mechanic.
263 pub fn fb_remb(&self) -> bool {
264 self.fb_remb
265 }
266 
267 pub(crate) fn match_score(&self, o: &PayloadParams) -> Option<usize> {
268 // we don't want to compare PT
269 let c0 = self.spec;
270 let c1 = o.spec;
271 
272 if c0 == c1 {
273 // Exact match
274 return Some(Self::EXACT_MATCH_SCORE);
275 }
276 
277 // Attempt fuzzy matching, since we don't have an exact match
278 if c0.codec != c1.codec || (c0.codec == Codec::Unknown && c1.codec == Codec::Unknown) {
279 // Codecs must match
280 return None;
281 }
282 
283 if c0.clock_rate != c1.clock_rate {
284 // Clock rates must match
285 return None;
286 }
287 
288 if c0.channels != c1.channels {
289 // Channels must match
290 return None;
291 }
292 
293 if c0.codec.is_audio() && c0.codec == Codec::Opus {
294 return Some(Self::match_opus_score(c0, c1));
295 }
296 
297 if c0.codec == Codec::H264 {
298 return Self::match_h264_score(c0, c1);
299 }
300 
301 if c0.codec == Codec::H265 {
302 return Self::match_h265_score(c0, c1);
303 }
304 
305 if c0.codec == Codec::H266 {
306 return Self::match_h266_score(c0, c1);
307 }
308 
309 if c0.codec == Codec::Vp9 {
310 return Self::match_vp9_score(c0, c1);
311 }
312 
313 if c0.codec == Codec::Av1 {
314 return Self::match_av1_score(c0, c1);
315 }
316 
317 // TODO: Fuzzy matching for any other audio codecs
318 // TODO: Fuzzy matching for video
319 
320 None
321 }
322 
323 fn match_opus_score(c0: CodecSpec, c1: CodecSpec) -> usize {
324 let mut score: usize = Self::EXACT_MATCH_SCORE;
325 
326 // If neither value is specified both sides should assume the default value, 3.
327 let either_p_time_specified =
328 c0.format.min_p_time.is_some() || c1.format.min_p_time.is_some();
329 if either_p_time_specified && c0.format.min_p_time != c1.format.min_p_time {
330 score = score.saturating_sub(1);
331 }
332 
333 // If neither value is specified both sides should assume FEC is not used as this is
334 // the default.
335 let either_fec_specified =
336 c0.format.use_inband_fec.is_some() || c1.format.use_inband_fec.is_some();
337 
338 if either_fec_specified && c0.format.use_inband_fec != c1.format.use_inband_fec {
339 score = score.saturating_sub(2);
340 }
341 
342 // If neither value is specified both sides should assume DTX is not used as this is
343 // the default.
344 let either_dtx_specified = c0.format.use_dtx.is_some() || c1.format.use_dtx.is_some();
345 if either_dtx_specified && c0.format.use_dtx != c1.format.use_dtx {
346 score = score.saturating_sub(4);
347 }
348 
349 // If neither value is specified both sides should assume mono for stereo
350 // and the sprop-stereo hint defaults to false.
351 let c0_stereo = c0.format.stereo.unwrap_or(false);
352 let c1_stereo = c1.format.stereo.unwrap_or(false);
353 if c0_stereo != c1_stereo {
354 score = score.saturating_sub(8);
355 }
356 
357 let c0_sprop_stereo = c0.format.sprop_stereo.unwrap_or(false);
358 let c1_sprop_stereo = c1.format.sprop_stereo.unwrap_or(false);
359 if c0_sprop_stereo != c1_sprop_stereo {
360 score = score.saturating_sub(8);
361 }
362 
363 score
364 }
365 
366 fn match_vp9_score(c0: CodecSpec, c1: CodecSpec) -> Option<usize> {
367 // Default profile_id is 0. https://datatracker.ietf.org/doc/html/draft-ietf-payload-vp9-16#section-6
368 let c0_profile_id = c0.format.profile_id.unwrap_or(0);
369 let c1_profile_id = c1.format.profile_id.unwrap_or(0);
370 
371 if c0_profile_id != c1_profile_id {
372 return None;
373 }
374 
375 Some(Self::EXACT_MATCH_SCORE)
376 }
377 
378 fn match_av1_score(c0: CodecSpec, c1: CodecSpec) -> Option<usize> {
379 // TODO: consider media direction for a proper less or equal matching
380 // The AV1 stream sent by either the offerer or the answerer MUST be
381 // encoded with a profile, level and tier, lesser or equal to the values
382 // of the level-idx, profile and tier declared in the SDP by the receiving
383 // agent.
384 // https://aomediacodec.github.io/av1-rtp-spec/#723-usage-with-the-sdp-offeranswer-model
385 
386 // Default values: profile = 0, level-idx = 5, tier = 0
387 // https://aomediacodec.github.io/av1-rtp-spec/#72-sdp-parameters
388 let c0_profile = c0.format.profile.unwrap_or(0);
389 let c1_profile = c1.format.profile.unwrap_or(0);
390 if c0_profile != c1_profile {
391 return None;
392 }
393 
394 let c0_level_idx = c0.format.level_idx.unwrap_or(5);
395 let c1_level_idx = c1.format.level_idx.unwrap_or(5);
396 if c0_level_idx != c1_level_idx {
397 return None;
398 }
399 
400 let c0_tier = c0.format.tier.unwrap_or(0);
401 let c1_tier = c1.format.tier.unwrap_or(0);
402 if c0_tier != c1_tier {
403 return None;
404 }
405 
406 Some(Self::EXACT_MATCH_SCORE)
407 }
408 
409 pub(crate) fn match_h264_score(c0: CodecSpec, c1: CodecSpec) -> Option<usize> {
410 // Default packetization mode is 0. https://www.rfc-editor.org/rfc/rfc6184#section-6.2
411 let c0_packetization_mode = c0.format.packetization_mode.unwrap_or(0);
412 let c1_packetization_mode = c1.format.packetization_mode.unwrap_or(0);
413 
414 if c0_packetization_mode != c1_packetization_mode {
415 return None;
416 }
417 
418 let c0_profile_level = c0
419 .format
420 .profile_level_id
421 .map(|l| l.try_into().ok())
422 .unwrap_or(Some(H264ProfileLevel::FALLBACK))?;
423 let c1_profile_level = c1
424 .format
425 .profile_level_id
426 .map(|l| l.try_into().ok())
427 .unwrap_or(Some(H264ProfileLevel::FALLBACK))?;
428 
429 // RFC 6184 Section 8.2.2: Profiles must match exactly.
430 if c0_profile_level.profile() != c1_profile_level.profile() {
431 return None;
432 }
433 
434 // RFC 6184 Section 8.2.2: the level is a negotiable (downgradable)
435 // parameter, so a level mismatch in either direction is allowed and
436 // only penalizes the match score rather than rejecting the payload.
437 //
438 // NOTE: this only affects *matching*. Unlike H.265/H.266, `update_param`
439 // does not narrow the answered H.264 level to `min(local, remote)`, so
440 // when `level-asymmetry-allowed` is absent we may answer with a level
441 // higher than the offer. See the note in `update_param`.
442 let level_difference: usize = c0_profile_level
443 .level()
444 .ordinal()
445 .abs_diff(c1_profile_level.level().ordinal());
446 
447 Some(Self::EXACT_MATCH_SCORE.saturating_sub(level_difference))
448 }
449 
450 /// Match H.265 codec specifications and return compatibility score.
451 ///
452 /// # Matching Rules — Full PTL (both sides have profile+tier+level)
453 ///
454 /// - **Profiles** must match exactly (no cross-profile compatibility)
455 /// - **Tiers** must match exactly (Main vs High tier are distinct)
456 /// - **Levels** penalize score by `|c0_level - c1_level|` but never reject.
457 /// Both H.264 and H.265 tolerate level mismatches in either direction at
458 /// match time. For H.265, `update_param` additionally narrows the
459 /// negotiated level to `min(local, remote)` afterward; H.264 does not.
460 ///
461 /// # Matching Rules — Profile-only (at least one side lacks tier+level)
462 ///
463 /// - **Profiles** must match; missing profile info falls back to
464 /// `FALLBACK.profile()` (not rejected).
465 /// - **Tiers and levels** are not checked (insufficient information).
466 /// - Returns a lower score (90) so full-PTL matches are preferred.
467 ///
468 /// # Returns
469 ///
470 /// - `Some(100)` for exact full-PTL match
471 /// - `Some(100 - level_gap)` for profile/tier match with level difference
472 /// - `Some(90)` for profile-only match (when tier/level unavailable)
473 /// - `None` for profile or tier mismatch
474 pub(crate) fn match_h265_score(c0: CodecSpec, c1: CodecSpec) -> Option<usize> {
475 match (
476 c0.format.h265_profile_tier_level,
477 c1.format.h265_profile_tier_level,
478 ) {
479 (Some(c0_ptl), Some(c1_ptl)) => {
480 // Both have full PTL - strict matching
481 
482 // Profiles must match exactly.
483 // RFC 7798 §7.2.2: profile-id is a configuration parameter
484 // that MUST be used symmetrically in offer/answer.
485 // https://www.rfc-editor.org/rfc/rfc7798#section-7.2.2
486 if c0_ptl.profile() != c1_ptl.profile() {
487 return None;
488 }
489 
490 // Tiers must match exactly.
491 // RFC 7798 §7.2.2: tier-flag is a configuration parameter
492 // that MUST be used symmetrically in offer/answer.
493 // https://www.rfc-editor.org/rfc/rfc7798#section-7.2.2
494 if c0_ptl.tier() != c1_ptl.tier() {
495 return None;
496 }
497 
498 // Level difference penalizes score but never causes rejection.
499 // Actual level narrowing to min(local, remote) is done in update_param.
500 let level_difference: usize =
501 c0_ptl.level().ordinal().abs_diff(c1_ptl.level().ordinal());
502 
503 // Pure scoring without artificial floor - let caller decide policy
504 Some(Self::EXACT_MATCH_SCORE.saturating_sub(level_difference))
505 }
506 _ => {
507 // At least one side has only profile-id (not full PTL).
508 // Match on profile only, using FALLBACK for missing values.
509 
510 // Get profile from PTL if present, otherwise from profile_id field
511 let c0_profile = c0
512 .format
513 .h265_profile_tier_level
514 .map(|ptl| ptl.profile().to_id())
515 .or_else(|| c0.format.profile_id.map(|p| p as u8))
516 .unwrap_or(H265ProfileTierLevel::FALLBACK.profile().to_id());
517 
518 let c1_profile = c1
519 .format
520 .h265_profile_tier_level
521 .map(|ptl| ptl.profile().to_id())
522 .or_else(|| c1.format.profile_id.map(|p| p as u8))
523 .unwrap_or(H265ProfileTierLevel::FALLBACK.profile().to_id());
524 
525 // Profiles must match
526 if c0_profile != c1_profile {
527 return None;
528 }
529 
530 // When only profile is specified, we can't verify tier/level compatibility.
531 // Return a lower score to prefer exact PTL matches when available.
532 Some(Self::PROFILE_ONLY_MATCH_SCORE)
533 }
534 }
535 }
536 
537 pub(crate) fn match_h266_score(c0: CodecSpec, c1: CodecSpec) -> Option<usize> {
538 match (
539 c0.format.h266_profile_tier_level,
540 c1.format.h266_profile_tier_level,
541 ) {
542 (Some(c0_ptl), Some(c1_ptl)) => {
543 // Both have full PTL - strict matching
544 
545 // Profiles must match exactly.
546 // RFC 9328 §7.2: profile-id is a configuration parameter
547 // that MUST be used symmetrically in offer/answer.
548 if c0_ptl.profile() != c1_ptl.profile() {
549 return None;
550 }
551 
552 // Tiers must match exactly.
553 // RFC 9328 §7.2: tier-flag is a configuration parameter
554 // that MUST be used symmetrically in offer/answer.
555 if c0_ptl.tier() != c1_ptl.tier() {
556 return None;
557 }
558 
559 // Level difference penalizes score but never causes rejection.
560 let level_difference: usize =
561 c0_ptl.level().ordinal().abs_diff(c1_ptl.level().ordinal());
562 
563 Some(Self::EXACT_MATCH_SCORE.saturating_sub(level_difference))
564 }
565 _ => {
566 // At least one side has no full PTL (e.g. a GStreamer offer
567 // with a bare rtpmap and no fmtp). Match on profile only,
568 // using FALLBACK for missing values.
569 let c0_profile = c0
570 .format
571 .h266_profile_tier_level
572 .map(|ptl| ptl.profile().to_id())
573 .or_else(|| c0.format.profile_id.map(|p| p as u8))
574 .unwrap_or(H266ProfileTierLevel::FALLBACK.profile().to_id());
575 
576 let c1_profile = c1
577 .format
578 .h266_profile_tier_level
579 .map(|ptl| ptl.profile().to_id())
580 .or_else(|| c1.format.profile_id.map(|p| p as u8))
581 .unwrap_or(H266ProfileTierLevel::FALLBACK.profile().to_id());
582 
583 // Profiles must match
584 if c0_profile != c1_profile {
585 return None;
586 }
587 
588 Some(Self::PROFILE_ONLY_MATCH_SCORE)
589 }
590 }
591 }
592 
593 pub(crate) fn update_param(
594 &mut self,
595 remote_pts: &[PayloadParams],
596 claimed: &mut [bool; 128],
597 local_is_controlling: bool,
598 unlocked: &mut HashSet<Pt>,
599 ) {
600 let Some((first, _)) = remote_pts
601 .iter()
602 .filter_map(|p| self.match_score(p).map(|s| (p, s)))
603 .max_by_key(|(_, s)| *s)
604 else {
605 return;
606 };
607 
608 // Mirror the remote's H.265 fmtp shape: echo back only the params they offered.
609 if self.spec.codec == Codec::H265 && first.spec.codec == Codec::H265 {
610 if let Some(remote_ptl) = first.spec.format.h265_profile_tier_level {
611 // Narrow level to min(local, remote) so the negotiated level
612 // never exceeds either side's capability.
613 let negotiated_ptl =
614 if let Some(local_ptl) = self.spec.format.h265_profile_tier_level {
615 remote_ptl.with_level(std::cmp::min(local_ptl.level(), remote_ptl.level()))
616 } else {
617 remote_ptl
618 };
619 self.spec.format.h265_profile_tier_level = Some(negotiated_ptl);
620 // Avoid also serializing `profile-id` via the VP9 `profile_id` field.
621 self.spec.format.profile_id = None;
622 } else if let Some(profile_id) = first.spec.format.profile_id {
623 // Remote only offered `profile-id`.
624 self.spec.format.h265_profile_tier_level = None;
625 self.spec.format.profile_id = Some(profile_id);
626 } else {
627 // No remote H.265 fmtp, omit ours as well.
628 self.spec.format.h265_profile_tier_level = None;
629 self.spec.format.profile_id = None;
630 }
631 
632 // Adopt remote's sprop-max-don-diff so the depacketizer knows whether
633 // incoming packets contain DONL fields (RFC 7798 §7.1).
634 // The packetizer also uses this to decide whether to emit DONL.
635 if first.spec.format.sprop_max_don_diff.is_some() {
636 self.spec.format.sprop_max_don_diff = first.spec.format.sprop_max_don_diff;
637 }
638 }
639 
640 // Same negotiation for H.266 (RFC 9328 §7.2, offer/answer model §7.3.2).
641 if self.spec.codec == Codec::H266 && first.spec.codec == Codec::H266 {
642 if let Some(remote_ptl) = first.spec.format.h266_profile_tier_level {
643 // Narrow level to min(local, remote) so the negotiated level
644 // never exceeds either side's capability.
645 let negotiated_ptl =
646 if let Some(local_ptl) = self.spec.format.h266_profile_tier_level {
647 remote_ptl.with_level(std::cmp::min(local_ptl.level(), remote_ptl.level()))
648 } else {
649 remote_ptl
650 };
651 self.spec.format.h266_profile_tier_level = Some(negotiated_ptl);
652 // Avoid also serializing `profile-id` via the VP9 `profile_id` field.
653 self.spec.format.profile_id = None;
654 } else if let Some(profile_id) = first.spec.format.profile_id {
655 // Remote only offered `profile-id`.
656 self.spec.format.h266_profile_tier_level = None;
657 self.spec.format.profile_id = Some(profile_id);
658 } else {
659 // No remote H.266 fmtp, omit ours as well.
660 self.spec.format.h266_profile_tier_level = None;
661 self.spec.format.profile_id = None;
662 }
663 
664 // Adopt remote's sprop-max-don-diff (RFC 9328 §7.2) — enables
665 // DONL on both packetizer and depacketizer.
666 if first.spec.format.sprop_max_don_diff.is_some() {
667 self.spec.format.sprop_max_don_diff = first.spec.format.sprop_max_don_diff;
668 }
669 }
670 
671 // KNOWN LIMITATION: H.264 has no equivalent level-narrowing pass here.
672 // `match_h264_score` treats the level as negotiable, but we keep our
673 // locally configured `profile-level-id` in the answer instead of
674 // narrowing it to `min(local, remote)`. Per RFC 6184 §8.2.2, when
675 // `level-asymmetry-allowed` is not set the answer should use the lower
676 // level.
677 
678 let mut remote_pt = first.pt;
679 let mut remote_rtx = first.resend;
680 
681 if self.locked {
682 // This can happen if the incoming PTs are suggestions (send-direction) rather than demanded
683 // (receive-direction). We only want to warn if we get receive direction changes.
684 if local_is_controlling {
685 return;
686 }
687 // Just verify it's still the same. We should validate this in apply_offer/answer instead
688 // of ever seeing this error message.
689 if self.pt != remote_pt {
690 warn!("Ignore remote PT change {} => {}", self.pt, remote_pt);
691 }
692 
693 if self.resend != remote_rtx {
694 warn!(
695 "Ignore remote PT RTX change {:?} => {:?}",
696 self.resend, remote_rtx
697 );
698 }
699 } else {
700 // Before locking, check if the remote PT conflicts with an already locked PT.
701 // If we're receiving, we control what PTs to use,
702 // so we can remap the conflicting PT.
703 if local_is_controlling && claimed.is_claimed(remote_pt) {
704 // Prefer our configured PT, then fall back to any free PT.
705 let new_pt = (!claimed.is_claimed(self.pt))
706 .then_some(self.pt)
707 .or_else(|| claimed.find_unclaimed(PREFERED_RANGES, unlocked));
708 
709 if let Some(new_pt) = new_pt {
710 debug!(
711 "Remapped conflicting PT {} => {} for codec {:?}",
712 remote_pt, new_pt, self.spec.codec
713 );
714 remote_pt = new_pt;
715 unlocked.remove(&new_pt);
716 } else {
717 panic!("Exhausted all PT ranges, inconsistent PayloadParam state");
718 }
719 }
720 
721 // Lock down the PT
722 self.pt = remote_pt;
723 claimed.assert_claim_once(remote_pt);
724 
725 // Check if RTX PT also conflicts
726 if local_is_controlling {
727 if let Some(rtx) = remote_rtx {
728 if claimed.is_claimed(rtx) {
729 let new_rtx = self
730 .resend
731 .filter(|pt| !claimed.is_claimed(*pt))
732 .or_else(|| claimed.find_unclaimed(PREFERED_RANGES, unlocked));
733 
734 if let Some(new_rtx) = new_rtx {
735 debug!("Remapped conflicting RTX PT {:?} => {}", rtx, new_rtx);
736 remote_rtx = Some(new_rtx);
737 unlocked.remove(&new_rtx);
738 } else {
739 panic!("Exhausted all PT ranges, inconsistent PayloadParam state");
740 }
741 }
742 }
743 }
744 
745 // Lock down the RTX PT
746 self.resend = remote_rtx;
747 if let Some(rtx) = remote_rtx {
748 claimed.assert_claim_once(rtx);
749 }
750 
751 // This is now locked.
752 self.locked = true;
753 }
754 }
755}
756 
757#[cfg(test)]
758mod test {
759 use crate::rtp_::Frequency;
760 
761 use super::*;
762 use crate::format::{CodecSpec, FormatParams};
763 
764 mod h264 {
765 use super::*;
766 
767 fn h264_codec_spec(
768 level_asymmetry_allowed: Option<bool>,
769 packetization_mode: Option<u8>,
770 profile_level_id: Option<u32>,
771 ) -> CodecSpec {
772 CodecSpec {
773 codec: Codec::H264,
774 clock_rate: Frequency::NINETY_KHZ,
775 channels: None,
776 format: FormatParams {
777 level_asymmetry_allowed,
778 packetization_mode,
779 profile_level_id,
780 ..Default::default()
781 },
782 }
783 }
784 
785 #[test]
786 fn test_h264_profile_matching() {
787 struct Case {
788 c0: CodecSpec,
789 c1: CodecSpec,
790 must_match: bool,
791 msg: &'static str,
792 }
793 
794 let cases = [
795 Case {
796 c0: h264_codec_spec(None, None, Some(0x42E01F)),
797 c1: h264_codec_spec(None, None, Some(0x4DA01F)),
798 must_match: true,
799 msg: "0x42A01F and 0x4DF01F should match, they are both constrained baseline subprofile",
800 },
801 Case {
802 c0: h264_codec_spec(None, None, Some(0x42E01F)),
803 c1: h264_codec_spec(None, Some(1), Some(0x4DA01F)),
804 must_match: false,
805 msg: "0x42A01F and 0x4DF01F with differing packetization modes should not match",
806 },
807 Case {
808 c0: h264_codec_spec(None, Some(0), Some(0x422000)),
809 c1: h264_codec_spec(None, None, Some(0x42B00A)),
810 must_match: true,
811 msg: "0x424000 and 0x42B00A should match because they are both the baseline subprofile \
812 and the level idc of 0x42F01F will be adjusted to Level1B because the constraint \
813 set 3 flag is set",
814 },
815 ];
816 
817 for Case {
818 c0,
819 c1,
820 must_match,
821 msg,
822 } in cases.into_iter()
823 {
824 let matched = PayloadParams::match_h264_score(c0, c1).is_some();
825 assert_eq!(matched, must_match, "{msg}\nc0: {c0:#?}\nc1: {c1:#?}");
826 }
827 }
828 
829 #[test]
830 fn test_h264_level_matching_rfc_compliant() {
831 struct Case {
832 c0: CodecSpec,
833 c1: CodecSpec,
834 expected: Option<usize>,
835 msg: &'static str,
836 }
837 
838 let cases = [
839 // Test 1: Same profile, same level -> should match
840 Case {
841 c0: h264_codec_spec(None, None, Some(0x42e028)), // CB L4.0
842 c1: h264_codec_spec(None, None, Some(0x42e028)), // CB L4.0
843 expected: Some(PayloadParams::EXACT_MATCH_SCORE),
844 msg: "Same profile (CB) and same level (4.0) should match",
845 },
846 // Test 2: Same profile, offered level lower -> should match (RFC 6184)
847 Case {
848 c0: h264_codec_spec(None, None, Some(0x42e028)), // CB L4.0 (configured)
849 c1: h264_codec_spec(None, None, Some(0x42e01f)), // CB L3.1 (offered)
850 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 2),
851 msg: "Same profile (CB), offered level 3.1 < configured 4.0 should match per RFC 6184",
852 },
853 // Test 3: Same profile, offered level higher -> should match (RFC 6184)
854 Case {
855 c0: h264_codec_spec(None, None, Some(0x42e01f)), // CB L3.1 (configured)
856 c1: h264_codec_spec(None, None, Some(0x42e028)), // CB L4.0 (offered)
857 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 2),
858 msg: "Same profile (CB), offered level 4.0 > configured 3.1 should match, \
859 the level is negotiable per RFC 6184",
860 },
861 // Test 4: Different profiles -> should NOT match
862 Case {
863 c0: h264_codec_spec(None, None, Some(0x42e028)), // CB L4.0
864 c1: h264_codec_spec(None, None, Some(0x4d0028)), // Main L4.0
865 expected: None,
866 msg: "Different profiles (CB vs Main) should NOT match even with same level",
867 },
868 // Test 5: Main profile, offered lower level -> should match
869 Case {
870 c0: h264_codec_spec(None, None, Some(0x4d002a)), // Main L4.2 (configured)
871 c1: h264_codec_spec(None, None, Some(0x4d0028)), // Main L4.0 (offered)
872 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 2),
873 msg: "Same profile (Main), offered level 4.0 < configured 4.2 should match",
874 },
875 // Test 6: High profile, multiple levels down -> should match
876 Case {
877 c0: h264_codec_spec(None, None, Some(0x640033)), // High L5.1 (configured)
878 c1: h264_codec_spec(None, None, Some(0x64001f)), // High L3.1 (offered)
879 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 6),
880 msg: "Same profile (High), offered level 3.1 < configured 5.1 should match",
881 },
882 // Test 7: Baseline (not CB) with Level1B as offered
883 Case {
884 c0: h264_codec_spec(None, None, Some(0x42001f)), // Baseline L3.1
885 c1: h264_codec_spec(None, None, Some(0x420000)), // Baseline Level1B
886 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 8),
887 msg: "Same profile (Baseline), offered level 1B < configured 3.1 should match",
888 },
889 // Test 8: Baseline (not CB) offered lower level > should match with (special Level1B case)
890 Case {
891 c0: h264_codec_spec(None, None, Some(0x420000)), // Baseline Level1B
892 c1: h264_codec_spec(None, None, Some(0x42000a)), // Baseline L1
893 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 1),
894 msg: "Same profile (Baseline), offered level 1 < configured 1B should match",
895 },
896 // Test 9: Baseline (not CB) offered higher level -> should match (special Level1B case)
897 Case {
898 c0: h264_codec_spec(None, None, Some(0x42000a)), // Baseline L1
899 c1: h264_codec_spec(None, None, Some(0x420000)), // Baseline Level1B
900 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 1),
901 msg: "Same profile (Baseline), offered level 1B > configured 1 should match",
902 },
903 // Test 10: Offered Constrained Baseline level 5.2 is higher than
904 // the default configured 3.1. Must match.
905 Case {
906 c0: h264_codec_spec(Some(true), Some(1), Some(0x42e01f)), // CB L3.1 (configured)
907 c1: h264_codec_spec(Some(true), Some(1), Some(0x42e034)), // CB L5.2 (offered)
908 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 7),
909 msg: "Same profile (CB), offered level 5.2 > configured 3.1 should match",
910 },
911 // Test 11: Constrained High (profile-iop 0x0C) must be recognized
912 // and match a configured Constrained High.
913 Case {
914 c0: h264_codec_spec(Some(true), Some(1), Some(0x640c1f)), // CH L3.1 (configured)
915 c1: h264_codec_spec(Some(true), Some(1), Some(0x640c34)), // CH L5.2 (offered)
916 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 7),
917 msg: "Same profile (Constrained High), offered level 5.2 should match",
918 },
919 // Test 12: Constrained High is not the same profile as High.
920 Case {
921 c0: h264_codec_spec(None, None, Some(0x64001f)), // High L3.1
922 c1: h264_codec_spec(None, None, Some(0x640c1f)), // Constrained High L3.1
923 expected: None,
924 msg: "Different profiles (High vs Constrained High) should NOT match",
925 },
926 ];
927 
928 for Case {
929 c0,
930 c1,
931 expected,
932 msg,
933 } in cases.into_iter()
934 {
935 assert_eq!(
936 PayloadParams::match_h264_score(c0, c1),
937 expected,
938 "{msg}\nc0: {c0:#?}\nc1: {c1:#?}"
939 );
940 }
941 }
942 }
943 
944 mod h265 {
945 use super::*;
946 use crate::packet::Packetizer;
947 
948 fn h265_codec_spec(profile_id: u8, tier_flag: u8, level_id: u8) -> CodecSpec {
949 CodecSpec {
950 codec: Codec::H265,
951 clock_rate: Frequency::NINETY_KHZ,
952 channels: None,
953 format: FormatParams {
954 h265_profile_tier_level: Some(
955 H265ProfileTierLevel::new(profile_id, tier_flag, level_id).unwrap(),
956 ),
957 ..Default::default()
958 },
959 }
960 }
961 
962 fn h265_codec_spec_profile_only(profile_id: u32) -> CodecSpec {
963 CodecSpec {
964 codec: Codec::H265,
965 clock_rate: Frequency::NINETY_KHZ,
966 channels: None,
967 format: FormatParams {
968 profile_id: Some(profile_id),
969 ..Default::default()
970 },
971 }
972 }
973 
974 /// Test basic H.265 profile/tier/level matching scenarios.
975 /// Verifies that full ProfileTierLevel parameters are matched correctly.
976 #[test]
977 fn test_profile_tier_level_matching() {
978 struct Case {
979 c0: CodecSpec,
980 c1: CodecSpec,
981 must_match: bool,
982 msg: &'static str,
983 }
984 
985 let cases = [
986 // Same profile, tier, level -> should match
987 Case {
988 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
989 c1: h265_codec_spec(1, 0, 93),
990 must_match: true,
991 msg: "Same profile (Main), tier (Main), level (3.1) should match",
992 },
993 // Same profile and tier, offered level lower -> should match
994 Case {
995 c0: h265_codec_spec(1, 0, 120), // Main, Main tier, Level 4.0
996 c1: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
997 must_match: true,
998 msg: "Same profile/tier, offered level 3.1 < configured 4.0 should match",
999 },
1000 // Same profile and tier, offered level higher -> should still match (with penalty)
1001 Case {
1002 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1003 c1: h265_codec_spec(1, 0, 120), // Main, Main tier, Level 4.0
1004 must_match: true,
1005 msg: "Same profile/tier, offered level 4.0 > configured 3.1 should match (penalized)",
1006 },
1007 // Different profiles -> should NOT match
1008 Case {
1009 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1010 c1: h265_codec_spec(2, 0, 93), // Main10, Main tier, Level 3.1
1011 must_match: false,
1012 msg: "Different profiles (Main vs Main10) should NOT match",
1013 },
1014 // Different tiers -> should NOT match
1015 Case {
1016 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1017 c1: h265_codec_spec(1, 1, 93), // Main, High tier, Level 3.1
1018 must_match: false,
1019 msg: "Different tiers (Main vs High) should NOT match",
1020 },
1021 // Main10 profile, same tier, lower level -> should match
1022 Case {
1023 c0: h265_codec_spec(2, 0, 153), // Main10, Main tier, Level 5.1
1024 c1: h265_codec_spec(2, 0, 120), // Main10, Main tier, Level 4.0
1025 must_match: true,
1026 msg: "Main10 profile, offered level 4.0 < configured 5.1 should match",
1027 },
1028 // High tier, multiple levels down -> should match
1029 Case {
1030 c0: h265_codec_spec(1, 1, 153), // Main, High tier, Level 5.1
1031 c1: h265_codec_spec(1, 1, 93), // Main, High tier, Level 3.1
1032 must_match: true,
1033 msg: "High tier, offered level 3.1 < configured 5.1 should match",
1034 },
1035 ];
1036 
1037 for Case {
1038 c0,
1039 c1,
1040 must_match,
1041 msg,
1042 } in cases.into_iter()
1043 {
1044 let matched = PayloadParams::match_h265_score(c0, c1).is_some();
1045 assert_eq!(matched, must_match, "{msg}\nc0: {c0:#?}\nc1: {c1:#?}");
1046 }
1047 }
1048 
1049 /// Test H.265 level matching with exact scores.
1050 /// Verifies that the score decrements based on level difference.
1051 #[test]
1052 fn test_level_matching_scores() {
1053 struct Case {
1054 c0: CodecSpec,
1055 c1: CodecSpec,
1056 expected: Option<usize>,
1057 msg: &'static str,
1058 }
1059 
1060 let cases = [
1061 // Exact match -> score 100
1062 Case {
1063 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1064 c1: h265_codec_spec(1, 0, 93),
1065 expected: Some(PayloadParams::EXACT_MATCH_SCORE),
1066 msg: "Exact match should return score 100",
1067 },
1068 // One level down -> score 100 - 1
1069 Case {
1070 c0: h265_codec_spec(1, 0, 120), // Main, Main tier, Level 4.0
1071 c1: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1072 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 1),
1073 msg: "One level difference should return score 99",
1074 },
1075 // Two levels down -> score 100 - 2
1076 Case {
1077 c0: h265_codec_spec(1, 0, 123), // Main, Main tier, Level 4.1
1078 c1: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1079 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 2),
1080 msg: "Two level difference should return score 98",
1081 },
1082 // Multiple levels down
1083 Case {
1084 c0: h265_codec_spec(1, 0, 153), // Main, Main tier, Level 5.1
1085 c1: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1086 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 4),
1087 msg: "Four level difference (5.1 to 3.1) should return score 96",
1088 },
1089 // Offered level higher -> still matches, penalized by 1 ordinal
1090 Case {
1091 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1092 c1: h265_codec_spec(1, 0, 120), // Main, Main tier, Level 4.0
1093 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 1),
1094 msg: "Offered level one higher than configured should return score 99",
1095 },
1096 // Different profiles -> None
1097 Case {
1098 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1099 c1: h265_codec_spec(2, 0, 93), // Main10, Main tier, Level 3.1
1100 expected: None,
1101 msg: "Different profiles should return None",
1102 },
1103 // Different tiers -> None
1104 Case {
1105 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1106 c1: h265_codec_spec(1, 1, 93), // Main, High tier, Level 3.1
1107 expected: None,
1108 msg: "Different tiers should return None",
1109 },
1110 ];
1111 
1112 for Case {
1113 c0,
1114 c1,
1115 expected,
1116 msg,
1117 } in cases.into_iter()
1118 {
1119 assert_eq!(
1120 PayloadParams::match_h265_score(c0, c1),
1121 expected,
1122 "{msg}\nc0: {c0:#?}\nc1: {c1:#?}"
1123 );
1124 }
1125 }
1126 
1127 /// Test H.265 profile-only matching (for Chrome compatibility).
1128 /// When only profile-id is provided without tier/level, match on profile only.
1129 #[test]
1130 fn test_profile_only_matching() {
1131 struct Case {
1132 c0: CodecSpec,
1133 c1: CodecSpec,
1134 expected: Option<usize>,
1135 msg: &'static str,
1136 }
1137 
1138 let cases = [
1139 // Both have profile-only, same profile -> score 90
1140 Case {
1141 c0: h265_codec_spec_profile_only(1), // Main
1142 c1: h265_codec_spec_profile_only(1), // Main
1143 expected: Some(PayloadParams::PROFILE_ONLY_MATCH_SCORE),
1144 msg: "Profile-only match (Main) should return score 90",
1145 },
1146 // Both have profile-only, different profiles -> None
1147 Case {
1148 c0: h265_codec_spec_profile_only(1), // Main
1149 c1: h265_codec_spec_profile_only(2), // Main10
1150 expected: None,
1151 msg: "Profile-only with different profiles should return None",
1152 },
1153 // One has full PTL, other has profile-only, same profile -> score 90
1154 Case {
1155 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1156 c1: h265_codec_spec_profile_only(1), // Main
1157 expected: Some(PayloadParams::PROFILE_ONLY_MATCH_SCORE),
1158 msg: "Mixed PTL and profile-only with matching profile should return score 90",
1159 },
1160 // One has full PTL, other has profile-only, different profiles -> None
1161 Case {
1162 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1163 c1: h265_codec_spec_profile_only(2), // Main10
1164 expected: None,
1165 msg: "Mixed PTL and profile-only with different profiles should return None",
1166 },
1167 // Reverse: profile-only vs full PTL, same profile -> score 90
1168 Case {
1169 c0: h265_codec_spec_profile_only(1), // Main
1170 c1: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1171 expected: Some(PayloadParams::PROFILE_ONLY_MATCH_SCORE),
1172 msg: "Profile-only vs full PTL with matching profile should return score 90",
1173 },
1174 ];
1175 
1176 for Case {
1177 c0,
1178 c1,
1179 expected,
1180 msg,
1181 } in cases.into_iter()
1182 {
1183 assert_eq!(
1184 PayloadParams::match_h265_score(c0, c1),
1185 expected,
1186 "{msg}\nc0: {c0:#?}\nc1: {c1:#?}"
1187 );
1188 }
1189 }
1190 
1191 /// Test negative cases for H.265 matching to ensure proper rejection.
1192 #[test]
1193 fn test_negative_cases() {
1194 struct Case {
1195 c0: CodecSpec,
1196 c1: CodecSpec,
1197 msg: &'static str,
1198 }
1199 
1200 let cases = [
1201 // Profile mismatch with full PTL
1202 Case {
1203 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1204 c1: h265_codec_spec(2, 0, 93), // Main10, Main tier, Level 3.1
1205 msg: "Profile mismatch (Main vs Main10) must not match",
1206 },
1207 // Tier mismatch
1208 Case {
1209 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1210 c1: h265_codec_spec(1, 1, 93), // Main, High tier, Level 3.1
1211 msg: "Tier mismatch (Main vs High) must not match",
1212 },
1213 // All three different (profile+tier mismatch rejects)
1214 Case {
1215 c0: h265_codec_spec(1, 0, 93), // Main, Main tier, Level 3.1
1216 c1: h265_codec_spec(2, 1, 153), // Main10, High tier, Level 5.1
1217 msg: "Profile and tier different must not match",
1218 },
1219 // Profile-only mismatch
1220 Case {
1221 c0: h265_codec_spec_profile_only(1), // Main
1222 c1: h265_codec_spec_profile_only(2), // Main10
1223 msg: "Profile-only mismatch must not match",
1224 },
1225 // Mixed full PTL vs profile-only with different profiles
1226 Case {
1227 c0: h265_codec_spec(2, 0, 93), // Main10, Main tier, Level 3.1
1228 c1: h265_codec_spec_profile_only(1), // Main
1229 msg: "Full PTL (Main10) vs profile-only (Main) must not match",
1230 },
1231 ];
1232 
1233 for Case { c0, c1, msg } in cases.into_iter() {
1234 assert_eq!(
1235 PayloadParams::match_h265_score(c0, c1),
1236 None,
1237 "{msg}\nc0: {c0:#?}\nc1: {c1:#?}"
1238 );
1239 }
1240 }
1241 
1242 /// Test that sprop-max-don-diff is adopted from remote SDP during negotiation,
1243 /// and that packetizer/depacketizer are correctly initialized with DONL enabled.
1244 #[test]
1245 fn test_sprop_max_don_diff_negotiation_enables_donl() {
1246 use crate::packet::{CodecDepacketizer, CodecPacketizer};
1247 
1248 // Local config: no sprop-max-don-diff (default)
1249 let local_spec = h265_codec_spec(1, 0, 93);
1250 let mut local_params = PayloadParams::new(
1251 Pt::new_with_value(102),
1252 Some(Pt::new_with_value(103)),
1253 local_spec,
1254 );
1255 assert_eq!(
1256 local_params.spec.format.sprop_max_don_diff, None,
1257 "Local should have no sprop-max-don-diff initially"
1258 );
1259 
1260 // Remote offers H.265 with sprop-max-don-diff=32
1261 let mut remote_spec = h265_codec_spec(1, 0, 93);
1262 remote_spec.format.sprop_max_don_diff = Some(32);
1263 let remote_params = PayloadParams::new(
1264 Pt::new_with_value(102),
1265 Some(Pt::new_with_value(103)),
1266 remote_spec,
1267 );
1268 
1269 // Simulate SDP negotiation (update_param merges remote into local)
1270 let mut claimed = [false; 128];
1271 let mut unlocked = std::collections::HashSet::new();
1272 local_params.update_param(
1273 &[remote_params],
1274 &mut claimed,
1275 false, // remote is controlling (we're answering)
1276 &mut unlocked,
1277 );
1278 
1279 // Verify sprop-max-don-diff was adopted from remote
1280 assert_eq!(
1281 local_params.spec.format.sprop_max_don_diff,
1282 Some(32),
1283 "sprop-max-don-diff should be adopted from remote after negotiation"
1284 );
1285 
1286 // Verify packetizer is initialized with DONL enabled
1287 let donl_enabled = local_params.spec.format.sprop_max_don_diff.unwrap_or(0) > 0;
1288 assert!(
1289 donl_enabled,
1290 "DONL should be enabled when sprop-max-don-diff > 0"
1291 );
1292 
1293 let mut pack = CodecPacketizer::new(
1294 local_params.spec.codec,
1295 crate::format::Vp9PacketizerMode::default(),
1296 );
1297 if let CodecPacketizer::H265(ref mut h265) = pack {
1298 h265.with_donl(donl_enabled);
1299 }
1300 
1301 // Packetize a NAL — should include DONL field (2 extra bytes)
1302 let nalu = vec![0x02, 0x01, 0xAA, 0xBB];
1303 let packets = pack.packetize(1200, &nalu).unwrap();
1304 assert_eq!(packets.len(), 1);
1305 assert_eq!(
1306 packets[0].len(),
1307 nalu.len() + 2, // +2 for DONL
1308 "Packetized output should include 2-byte DONL field"
1309 );
1310 
1311 // Verify depacketizer is initialized with DONL enabled
1312 let mut depack: CodecDepacketizer = local_params.spec.codec.into();
1313 if let CodecDepacketizer::H265(ref mut h265) = depack {
1314 h265.with_donl(donl_enabled);
1315 }
1316 
1317 // Depacketize the packet — should correctly strip DONL
1318 use crate::packet::{CodecExtra, Depacketizer};
1319 let mut out = Vec::new();
1320 let mut extra = CodecExtra::None;
1321 if let CodecDepacketizer::H265(ref mut h265) = depack {
1322 h265.depacketize(&packets[0], &mut out, &mut extra).unwrap();
1323 }
1324 
1325 // Output should be Annex-B: start code + original NAL
1326 assert_eq!(&out[0..4], &[0x00, 0x00, 0x00, 0x01]);
1327 assert_eq!(
1328 &out[4..],
1329 &nalu[..],
1330 "Depacketized NAL should match original"
1331 );
1332 }
1333 
1334 /// Test that sprop-max-don-diff=0 from remote does NOT enable DONL.
1335 #[test]
1336 fn test_sprop_max_don_diff_zero_disables_donl() {
1337 let local_spec = h265_codec_spec(1, 0, 93);
1338 let mut local_params = PayloadParams::new(
1339 Pt::new_with_value(102),
1340 Some(Pt::new_with_value(103)),
1341 local_spec,
1342 );
1343 
1344 // Remote offers with sprop-max-don-diff=0 (no reordering)
1345 let mut remote_spec = h265_codec_spec(1, 0, 93);
1346 remote_spec.format.sprop_max_don_diff = Some(0);
1347 let remote_params = PayloadParams::new(
1348 Pt::new_with_value(102),
1349 Some(Pt::new_with_value(103)),
1350 remote_spec,
1351 );
1352 
1353 let mut claimed = [false; 128];
1354 let mut unlocked = std::collections::HashSet::new();
1355 local_params.update_param(&[remote_params], &mut claimed, false, &mut unlocked);
1356 
1357 assert_eq!(local_params.spec.format.sprop_max_don_diff, Some(0));
1358 let donl_enabled = local_params.spec.format.sprop_max_don_diff.unwrap_or(0) > 0;
1359 assert!(
1360 !donl_enabled,
1361 "DONL should NOT be enabled when sprop-max-don-diff=0"
1362 );
1363 
1364 // Packetize without DONL — packet should be same size as NAL
1365 let nalu = vec![0x02, 0x01, 0xAA, 0xBB];
1366 let mut pack = crate::packet::CodecPacketizer::new(
1367 local_params.spec.codec,
1368 crate::format::Vp9PacketizerMode::default(),
1369 );
1370 // Don't call with_donl(true) since donl_enabled is false
1371 let packets = pack.packetize(1200, &nalu).unwrap();
1372 assert_eq!(
1373 packets[0].len(),
1374 nalu.len(),
1375 "No DONL field when sprop-max-don-diff=0"
1376 );
1377 }
1378 
1379 /// Test that missing sprop-max-don-diff from remote preserves local value.
1380 #[test]
1381 fn test_sprop_max_don_diff_absent_preserves_local() {
1382 // Local has sprop-max-don-diff=16
1383 let mut local_spec = h265_codec_spec(1, 0, 93);
1384 local_spec.format.sprop_max_don_diff = Some(16);
1385 let mut local_params = PayloadParams::new(
1386 Pt::new_with_value(102),
1387 Some(Pt::new_with_value(103)),
1388 local_spec,
1389 );
1390 
1391 // Remote offers without sprop-max-don-diff
1392 let remote_spec = h265_codec_spec(1, 0, 93);
1393 assert!(remote_spec.format.sprop_max_don_diff.is_none());
1394 let remote_params = PayloadParams::new(
1395 Pt::new_with_value(102),
1396 Some(Pt::new_with_value(103)),
1397 remote_spec,
1398 );
1399 
1400 let mut claimed = [false; 128];
1401 let mut unlocked = std::collections::HashSet::new();
1402 local_params.update_param(&[remote_params], &mut claimed, false, &mut unlocked);
1403 
1404 // Local value should be preserved (remote didn't override)
1405 assert_eq!(
1406 local_params.spec.format.sprop_max_don_diff,
1407 Some(16),
1408 "Local sprop-max-don-diff should be preserved when remote doesn't specify it"
1409 );
1410 }
1411 }
1412 
1413 mod h266 {
1414 use super::*;
1415 use crate::packet::H266ProfileTierLevel;
1416 use crate::packet::Packetizer;
1417 
1418 fn h266_codec_spec(profile_id: u8, tier_flag: u8, level_id: u8) -> CodecSpec {
1419 CodecSpec {
1420 codec: Codec::H266,
1421 clock_rate: Frequency::NINETY_KHZ,
1422 channels: None,
1423 format: FormatParams {
1424 h266_profile_tier_level: Some(
1425 H266ProfileTierLevel::new(profile_id, tier_flag, level_id).unwrap(),
1426 ),
1427 ..Default::default()
1428 },
1429 }
1430 }
1431 
1432 fn h266_codec_spec_profile_only(profile_id: u32) -> CodecSpec {
1433 CodecSpec {
1434 codec: Codec::H266,
1435 clock_rate: Frequency::NINETY_KHZ,
1436 channels: None,
1437 format: FormatParams {
1438 profile_id: Some(profile_id),
1439 ..Default::default()
1440 },
1441 }
1442 }
1443 
1444 /// Test basic H.266 profile/tier/level matching scenarios.
1445 /// Verifies that full ProfileTierLevel parameters are matched correctly.
1446 #[test]
1447 fn test_profile_tier_level_matching() {
1448 struct Case {
1449 c0: CodecSpec,
1450 c1: CodecSpec,
1451 must_match: bool,
1452 msg: &'static str,
1453 }
1454 
1455 let cases = [
1456 // Same profile, tier, level -> should match
1457 Case {
1458 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1459 c1: h266_codec_spec(1, 0, 51),
1460 must_match: true,
1461 msg: "Same profile (Main), tier (Main), level (3.1) should match",
1462 },
1463 // Same profile and tier, offered level lower -> should match
1464 Case {
1465 c0: h266_codec_spec(1, 0, 64), // Main, Main tier, Level 4.0
1466 c1: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1467 must_match: true,
1468 msg: "Same profile/tier, offered level 3.1 < configured 4.0 should match",
1469 },
1470 // Same profile and tier, offered level higher -> should still match (with penalty)
1471 Case {
1472 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1473 c1: h266_codec_spec(1, 0, 64), // Main, Main tier, Level 4.0
1474 must_match: true,
1475 msg: "Same profile/tier, offered level 4.0 > configured 3.1 should match (penalized)",
1476 },
1477 // Different profiles -> should NOT match
1478 Case {
1479 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1480 c1: h266_codec_spec(33, 0, 51), // Main10, Main tier, Level 3.1
1481 must_match: false,
1482 msg: "Different profiles (Main vs Main10) should NOT match",
1483 },
1484 // Different tiers -> should NOT match
1485 Case {
1486 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1487 c1: h266_codec_spec(1, 1, 51), // Main, High tier, Level 3.1
1488 must_match: false,
1489 msg: "Different tiers (Main vs High) should NOT match",
1490 },
1491 // Main10 profile, same tier, lower level -> should match
1492 Case {
1493 c0: h266_codec_spec(33, 0, 83), // Main10, Main tier, Level 5.1
1494 c1: h266_codec_spec(33, 0, 64), // Main10, Main tier, Level 4.0
1495 must_match: true,
1496 msg: "Main10 profile, offered level 4.0 < configured 5.1 should match",
1497 },
1498 // High tier, multiple levels down -> should match
1499 Case {
1500 c0: h266_codec_spec(1, 1, 83), // Main, High tier, Level 5.1
1501 c1: h266_codec_spec(1, 1, 51), // Main, High tier, Level 3.1
1502 must_match: true,
1503 msg: "High tier, offered level 3.1 < configured 5.1 should match",
1504 },
1505 ];
1506 
1507 for Case {
1508 c0,
1509 c1,
1510 must_match,
1511 msg,
1512 } in cases.into_iter()
1513 {
1514 let matched = PayloadParams::match_h266_score(c0, c1).is_some();
1515 assert_eq!(matched, must_match, "{msg}\nc0: {c0:#?}\nc1: {c1:#?}");
1516 }
1517 }
1518 
1519 /// Test H.266 level matching with exact scores.
1520 /// Verifies that the score decrements based on level difference.
1521 #[test]
1522 fn test_level_matching_scores() {
1523 struct Case {
1524 c0: CodecSpec,
1525 c1: CodecSpec,
1526 expected: Option<usize>,
1527 msg: &'static str,
1528 }
1529 
1530 let cases = [
1531 // Exact match -> score 100
1532 Case {
1533 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1534 c1: h266_codec_spec(1, 0, 51),
1535 expected: Some(PayloadParams::EXACT_MATCH_SCORE),
1536 msg: "Exact match should return score 100",
1537 },
1538 // One level down -> score 100 - 1
1539 Case {
1540 c0: h266_codec_spec(1, 0, 64), // Main, Main tier, Level 4.0
1541 c1: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1542 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 1),
1543 msg: "One level difference should return score 99",
1544 },
1545 // Two levels down -> score 100 - 2
1546 Case {
1547 c0: h266_codec_spec(1, 0, 67), // Main, Main tier, Level 4.1
1548 c1: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1549 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 2),
1550 msg: "Two level difference should return score 98",
1551 },
1552 // Multiple levels down
1553 Case {
1554 c0: h266_codec_spec(1, 0, 83), // Main, Main tier, Level 5.1
1555 c1: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1556 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 4),
1557 msg: "Four level difference (5.1 to 3.1) should return score 96",
1558 },
1559 // Offered level higher -> still matches, penalized by 1 ordinal
1560 Case {
1561 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1562 c1: h266_codec_spec(1, 0, 64), // Main, Main tier, Level 4.0
1563 expected: Some(PayloadParams::EXACT_MATCH_SCORE - 1),
1564 msg: "Offered level one higher than configured should return score 99",
1565 },
1566 // Different profiles -> None
1567 Case {
1568 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1569 c1: h266_codec_spec(33, 0, 51), // Main10, Main tier, Level 3.1
1570 expected: None,
1571 msg: "Different profiles should return None",
1572 },
1573 // Different tiers -> None
1574 Case {
1575 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1576 c1: h266_codec_spec(1, 1, 51), // Main, High tier, Level 3.1
1577 expected: None,
1578 msg: "Different tiers should return None",
1579 },
1580 ];
1581 
1582 for Case {
1583 c0,
1584 c1,
1585 expected,
1586 msg,
1587 } in cases.into_iter()
1588 {
1589 assert_eq!(
1590 PayloadParams::match_h266_score(c0, c1),
1591 expected,
1592 "{msg}\nc0: {c0:#?}\nc1: {c1:#?}"
1593 );
1594 }
1595 }
1596 
1597 /// Test H.266 profile-only matching (for Chrome compatibility).
1598 /// When only profile-id is provided without tier/level, match on profile only.
1599 #[test]
1600 fn test_profile_only_matching() {
1601 struct Case {
1602 c0: CodecSpec,
1603 c1: CodecSpec,
1604 expected: Option<usize>,
1605 msg: &'static str,
1606 }
1607 
1608 let cases = [
1609 // Both have profile-only, same profile -> score 90
1610 Case {
1611 c0: h266_codec_spec_profile_only(1), // Main
1612 c1: h266_codec_spec_profile_only(1), // Main
1613 expected: Some(PayloadParams::PROFILE_ONLY_MATCH_SCORE),
1614 msg: "Profile-only match (Main) should return score 90",
1615 },
1616 // Both have profile-only, different profiles -> None
1617 Case {
1618 c0: h266_codec_spec_profile_only(1), // Main
1619 c1: h266_codec_spec_profile_only(33), // Main10
1620 expected: None,
1621 msg: "Profile-only with different profiles should return None",
1622 },
1623 // One has full PTL, other has profile-only, same profile -> score 90
1624 Case {
1625 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1626 c1: h266_codec_spec_profile_only(1), // Main
1627 expected: Some(PayloadParams::PROFILE_ONLY_MATCH_SCORE),
1628 msg: "Mixed PTL and profile-only with matching profile should return score 90",
1629 },
1630 // One has full PTL, other has profile-only, different profiles -> None
1631 Case {
1632 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1633 c1: h266_codec_spec_profile_only(33), // Main10
1634 expected: None,
1635 msg: "Mixed PTL and profile-only with different profiles should return None",
1636 },
1637 // Reverse: profile-only vs full PTL, same profile -> score 90
1638 Case {
1639 c0: h266_codec_spec_profile_only(1), // Main
1640 c1: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1641 expected: Some(PayloadParams::PROFILE_ONLY_MATCH_SCORE),
1642 msg: "Profile-only vs full PTL with matching profile should return score 90",
1643 },
1644 ];
1645 
1646 for Case {
1647 c0,
1648 c1,
1649 expected,
1650 msg,
1651 } in cases.into_iter()
1652 {
1653 assert_eq!(
1654 PayloadParams::match_h266_score(c0, c1),
1655 expected,
1656 "{msg}\nc0: {c0:#?}\nc1: {c1:#?}"
1657 );
1658 }
1659 }
1660 
1661 /// Test negative cases for H.266 matching to ensure proper rejection.
1662 #[test]
1663 fn test_negative_cases() {
1664 struct Case {
1665 c0: CodecSpec,
1666 c1: CodecSpec,
1667 msg: &'static str,
1668 }
1669 
1670 let cases = [
1671 // Profile mismatch with full PTL
1672 Case {
1673 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1674 c1: h266_codec_spec(33, 0, 51), // Main10, Main tier, Level 3.1
1675 msg: "Profile mismatch (Main vs Main10) must not match",
1676 },
1677 // Tier mismatch
1678 Case {
1679 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1680 c1: h266_codec_spec(1, 1, 51), // Main, High tier, Level 3.1
1681 msg: "Tier mismatch (Main vs High) must not match",
1682 },
1683 // All three different (profile+tier mismatch rejects)
1684 Case {
1685 c0: h266_codec_spec(1, 0, 51), // Main, Main tier, Level 3.1
1686 c1: h266_codec_spec(33, 1, 83), // Main10, High tier, Level 5.1
1687 msg: "Profile and tier different must not match",
1688 },
1689 // Profile-only mismatch
1690 Case {
1691 c0: h266_codec_spec_profile_only(1), // Main
1692 c1: h266_codec_spec_profile_only(33), // Main10
1693 msg: "Profile-only mismatch must not match",
1694 },
1695 // Mixed full PTL vs profile-only with different profiles
1696 Case {
1697 c0: h266_codec_spec(33, 0, 51), // Main10, Main tier, Level 3.1
1698 c1: h266_codec_spec_profile_only(1), // Main
1699 msg: "Full PTL (Main10) vs profile-only (Main) must not match",
1700 },
1701 ];
1702 
1703 for Case { c0, c1, msg } in cases.into_iter() {
1704 assert_eq!(
1705 PayloadParams::match_h266_score(c0, c1),
1706 None,
1707 "{msg}\nc0: {c0:#?}\nc1: {c1:#?}"
1708 );
1709 }
1710 }
1711 
1712 /// Test that sprop-max-don-diff is adopted from remote SDP during negotiation,
1713 /// and that packetizer/depacketizer are correctly initialized with DONL enabled.
1714 #[test]
1715 fn test_sprop_max_don_diff_negotiation_enables_donl() {
1716 use crate::packet::{CodecDepacketizer, CodecPacketizer};
1717 
1718 // Local config: no sprop-max-don-diff (default)
1719 let local_spec = h266_codec_spec(1, 0, 51);
1720 let mut local_params = PayloadParams::new(
1721 Pt::new_with_value(102),
1722 Some(Pt::new_with_value(103)),
1723 local_spec,
1724 );
1725 assert_eq!(
1726 local_params.spec.format.sprop_max_don_diff, None,
1727 "Local should have no sprop-max-don-diff initially"
1728 );
1729 
1730 // Remote offers H.266 with sprop-max-don-diff=32
1731 let mut remote_spec = h266_codec_spec(1, 0, 51);
1732 remote_spec.format.sprop_max_don_diff = Some(32);
1733 let remote_params = PayloadParams::new(
1734 Pt::new_with_value(102),
1735 Some(Pt::new_with_value(103)),
1736 remote_spec,
1737 );
1738 
1739 // Simulate SDP negotiation (update_param merges remote into local)
1740 let mut claimed = [false; 128];
1741 let mut unlocked = std::collections::HashSet::new();
1742 local_params.update_param(
1743 &[remote_params],
1744 &mut claimed,
1745 false, // remote is controlling (we're answering)
1746 &mut unlocked,
1747 );
1748 
1749 // Verify sprop-max-don-diff was adopted from remote
1750 assert_eq!(
1751 local_params.spec.format.sprop_max_don_diff,
1752 Some(32),
1753 "sprop-max-don-diff should be adopted from remote after negotiation"
1754 );
1755 
1756 // Verify packetizer is initialized with DONL enabled
1757 let donl_enabled = local_params.spec.format.sprop_max_don_diff.unwrap_or(0) > 0;
1758 assert!(
1759 donl_enabled,
1760 "DONL should be enabled when sprop-max-don-diff > 0"
1761 );
1762 
1763 let mut pack = CodecPacketizer::new(
1764 local_params.spec.codec,
1765 crate::format::Vp9PacketizerMode::default(),
1766 );
1767 if let CodecPacketizer::H266(ref mut h266) = pack {
1768 h266.with_donl(donl_enabled);
1769 }
1770 
1771 // Packetize a NAL — should include DONL field (2 extra bytes)
1772 let nalu = vec![0x00, 0x09, 0xAA, 0xBB];
1773 let packets = pack.packetize(1200, &nalu).unwrap();
1774 assert_eq!(packets.len(), 1);
1775 assert_eq!(
1776 packets[0].len(),
1777 nalu.len() + 2, // +2 for DONL
1778 "Packetized output should include 2-byte DONL field"
1779 );
1780 
1781 // Verify depacketizer is initialized with DONL enabled
1782 let mut depack: CodecDepacketizer = local_params.spec.codec.into();
1783 if let CodecDepacketizer::H266(ref mut h266) = depack {
1784 h266.with_donl(donl_enabled);
1785 }
1786 
1787 // Depacketize the packet — should correctly strip DONL
1788 use crate::packet::{CodecExtra, Depacketizer};
1789 let mut out = Vec::new();
1790 let mut extra = CodecExtra::None;
1791 if let CodecDepacketizer::H266(ref mut h266) = depack {
1792 h266.depacketize(&packets[0], &mut out, &mut extra).unwrap();
1793 }
1794 
1795 // Output should be Annex-B: start code + original NAL
1796 assert_eq!(&out[0..4], &[0x00, 0x00, 0x00, 0x01]);
1797 assert_eq!(
1798 &out[4..],
1799 &nalu[..],
1800 "Depacketized NAL should match original"
1801 );
1802 }
1803 
1804 /// Test that missing sprop-max-don-diff from remote preserves local value.
1805 #[test]
1806 fn test_sprop_max_don_diff_absent_preserves_local() {
1807 // Local has sprop-max-don-diff=16
1808 let mut local_spec = h266_codec_spec(1, 0, 51);
1809 local_spec.format.sprop_max_don_diff = Some(16);
1810 let mut local_params = PayloadParams::new(
1811 Pt::new_with_value(102),
1812 Some(Pt::new_with_value(103)),
1813 local_spec,
1814 );
1815 
1816 // Remote offers without sprop-max-don-diff
1817 let remote_spec = h266_codec_spec(1, 0, 51);
1818 assert!(remote_spec.format.sprop_max_don_diff.is_none());
1819 let remote_params = PayloadParams::new(
1820 Pt::new_with_value(102),
1821 Some(Pt::new_with_value(103)),
1822 remote_spec,
1823 );
1824 
1825 let mut claimed = [false; 128];
1826 let mut unlocked = std::collections::HashSet::new();
1827 local_params.update_param(&[remote_params], &mut claimed, false, &mut unlocked);
1828 
1829 // Local value should be preserved (remote didn't override)
1830 assert_eq!(
1831 local_params.spec.format.sprop_max_don_diff,
1832 Some(16),
1833 "Local sprop-max-don-diff should be preserved when remote doesn't specify it"
1834 );
1835 }
1836 
1837 /// Test that sprop-max-don-diff=0 from remote does NOT enable DONL.
1838 #[test]
1839 fn test_sprop_max_don_diff_zero_disables_donl() {
1840 let local_spec = h266_codec_spec(1, 0, 51);
1841 let mut local_params = PayloadParams::new(
1842 Pt::new_with_value(102),
1843 Some(Pt::new_with_value(103)),
1844 local_spec,
1845 );
1846 
1847 // Remote offers with sprop-max-don-diff=0 (no reordering)
1848 let mut remote_spec = h266_codec_spec(1, 0, 51);
1849 remote_spec.format.sprop_max_don_diff = Some(0);
1850 let remote_params = PayloadParams::new(
1851 Pt::new_with_value(102),
1852 Some(Pt::new_with_value(103)),
1853 remote_spec,
1854 );
1855 
1856 let mut claimed = [false; 128];
1857 let mut unlocked = std::collections::HashSet::new();
1858 local_params.update_param(&[remote_params], &mut claimed, false, &mut unlocked);
1859 
1860 assert_eq!(local_params.spec.format.sprop_max_don_diff, Some(0));
1861 let donl_enabled = local_params.spec.format.sprop_max_don_diff.unwrap_or(0) > 0;
1862 assert!(
1863 !donl_enabled,
1864 "DONL should NOT be enabled when sprop-max-don-diff=0"
1865 );
1866 
1867 // Packetize without DONL — packet should be same size as NAL
1868 let nalu = vec![0x00, 0x09, 0xAA, 0xBB];
1869 let mut pack = crate::packet::CodecPacketizer::new(
1870 local_params.spec.codec,
1871 crate::format::Vp9PacketizerMode::default(),
1872 );
1873 // Don't enable DONL since donl_enabled is false
1874 let packets = pack.packetize(1200, &nalu).unwrap();
1875 assert_eq!(
1876 packets[0].len(),
1877 nalu.len(),
1878 "No DONL field when sprop-max-don-diff=0"
1879 );
1880 }
1881 }
1882}