Skip to main content

o_sfu_router/model/
rtp_negotiation.rs

1//! RTP capability matching between producers, routers, and consumers.
2//!
3//! Separates negotiation into two distinct stages to keep producer codecs
4//! decoupled from consumer capabilities:
5//!
6//! 1. **Ingress Normalization**: Producer formats are matched against router capabilities
7//!    to produce a standardized `Consumable` stream.
8//! 2. **Egress Selection**: Consumer capabilities are intersected with the `Consumable` stream
9//!    to determine supported formats, RTCP feedback, and congestion control modes.
10//!
11//! ```text
12//!                   Producer Stream                     Router Capabilities
13//!                          \                                   /
14//!                           v                                 v
15//!                +-------------------------------------------------------+
16//!                | Stage 1: Ingress Normalization                        |
17//!                | - Match primary codecs & assign router payload types  |
18//!                | - Remap RTX `apt` to negotiated primary payload types |
19//!                | - Intersect supported header extensions               |
20//!                +-------------------------------------------------------+
21//!                                            |
22//!                                            v
23//!                                Consumable MediaStream
24//!                                            |
25//!                                            +--------------------+
26//!                                            |                    |
27//!                                            v                    v
28//!                                  Consumer 1 Capabilities    Consumer 2 Capabilities
29//!                                            |                    |
30//!                                            v                    v
31//!                              +-----------------------+ +-----------------------+
32//!                              | Stage 2: Egress Match | | Stage 2: Egress Match |
33//!                              | - Codec intersection  | | - Codec intersection  |
34//!                              | - RTCP feedback match | | - RTCP feedback match |
35//!                              | - BWE policy (TWCC)   | | - BWE policy (REMB)   |
36//!                              +-----------------------+ +-----------------------+
37//!                                            |                    |
38//!                                            v                    v
39//!                                Consumer 1 Egress Stream    Consumer 2 Egress Stream
40//! ```
41//!
42//! This module operates purely on the typed domain model (`MediaStream`, `MediaFormat`,
43//! `HeaderExtension`, `StreamBinding`). Rules that depend on raw SDP session structure
44//! (m-line ordering, rejected m-sections, BUNDLE extmap consistency) are outside this module
45//! and belong at the signaling edge.
46
47use std::collections::HashSet;
48
49use o_sfu_rfc::rtp as rfc_rtp;
50
51#[cfg(any(test, feature = "test-support"))]
52use super::diagnostic::{ParseDiagnostic, ParseDiagnosticKind, ParseDiagnosticSpec, RfcReference};
53use super::{
54    CodecSetting, HeaderExtension, HeaderExtensionUri, MediaCapabilities, MediaCodec,
55    MediaCodecCapability, MediaFormat, MediaKind, MediaStream, PayloadType, RtcpFeedback,
56    RtcpFeedbackKind,
57};
58
59/// Failure raised while deriving or negotiating RTP parameters.
60#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61pub enum RtpNegotiationError {
62    #[error("unsupported producer codec {codec_name} payload {payload_type}")]
63    UnsupportedProducerCodec {
64        codec_name: String,
65        payload_type: u8,
66    },
67    #[error("invalid apt parameter for codec {codec_name} payload {payload_type}")]
68    InvalidAptParameter {
69        codec_name: String,
70        payload_type: u8,
71    },
72    #[error("missing media codec for rtx payload {payload_type} apt {associated_payload_type}")]
73    MissingAssociatedMediaCodecForRtx {
74        payload_type: u8,
75        associated_payload_type: u8,
76    },
77    #[error("no compatible consumer codec")]
78    NoCompatibleConsumerCodec,
79}
80
81#[cfg(any(test, feature = "test-support"))]
82const RFC_3264_SECTION_6: RfcReference = RfcReference::new(
83    "RFC 3264",
84    "section 6",
85    "https://www.rfc-editor.org/rfc/rfc3264.html#section-6",
86);
87#[cfg(any(test, feature = "test-support"))]
88const RFC_4588_SECTION_8_1: RfcReference = RfcReference::new(
89    "RFC 4588",
90    "section 8.1",
91    "https://www.rfc-editor.org/rfc/rfc4588.html#section-8.1",
92);
93
94#[cfg(any(test, feature = "test-support"))]
95impl ParseDiagnostic for RtpNegotiationError {
96    fn diagnostic(&self) -> ParseDiagnosticSpec {
97        match self {
98            Self::UnsupportedProducerCodec { .. } => ParseDiagnosticSpec::new(
99                ParseDiagnosticKind::UnsupportedFeature,
100                "producer codec is valid but not supported by router capabilities",
101                RFC_3264_SECTION_6,
102                "capture producer media stream and router media capabilities and replay derive_consumable_rtp_parameters",
103            ),
104            Self::InvalidAptParameter { .. } => ParseDiagnosticSpec::new(
105                ParseDiagnosticKind::InvalidInput,
106                "RTX codec has an invalid or missing apt parameter",
107                RFC_4588_SECTION_8_1,
108                "capture producer media stream and replay derive_consumable_rtp_parameters to inspect RTX linkage",
109            ),
110            Self::MissingAssociatedMediaCodecForRtx { .. } => ParseDiagnosticSpec::new(
111                ParseDiagnosticKind::InvalidInput,
112                "RTX codec references an associated payload type that is not negotiated",
113                RFC_4588_SECTION_8_1,
114                "capture producer media stream and replay derive_consumable_rtp_parameters to inspect RTX linkage",
115            ),
116            Self::NoCompatibleConsumerCodec => ParseDiagnosticSpec::new(
117                ParseDiagnosticKind::UnsupportedFeature,
118                "consumer capabilities have no compatible media codec with the consumable set",
119                RFC_3264_SECTION_6,
120                "capture consumable media stream and consumer capabilities and replay negotiate_consumer_rtp_parameters",
121            ),
122        }
123    }
124}
125
126/// Derives the router-consumable media stream from producer parameters.
127///
128/// # Two-Pass Codec Resolution
129///
130/// Retransmission (RTX) formats depend on an associated primary codec via RFC 4588 `apt`.
131/// Negotiation therefore executes in two passes:
132/// 1. **Pass 1 (Primary Codecs)**: Match primary media formats against router capabilities,
133///    assigning router-normalized payload types and building a translation map.
134/// 2. **Pass 2 (RTX Codecs)**: Match RTX repair formats, resolve their `apt` references against
135///    the translation map, and omit valid formats with no matching router RTX capability.
136///
137/// ```text
138/// Incoming Producer Formats:
139///   [ Format 0: RTX (PT 97, apt=96) ] <---+ (references PT 96)
140///   [ Format 1: VP8 (PT 96)         ] ----+
141///
142/// Pass 1: Primary Codec Matching (Skip RTX)
143///   Format 1 (VP8, PT 96) matches Router Capability (VP8, PT 100)
144///     ==> Consumable Primary: VP8 (PT 100)
145///     ==> Translation Map: [ Producer PT 96 -> Router PT 100 ]
146///
147/// Pass 2: RTX Matching & `apt` Rewriting
148///   Format 0 (RTX, PT 97, apt=96):
149///     - Lookup `apt=96` in Translation Map ==> matches Router PT 100
150///     - Match Router Capability for RTX with apt=100 ==> Router PT 101
151///     ==> Consumable Repair: RTX (PT 101, apt=100)
152///
153/// Final Consumable Stream:
154///   [ Primary: VP8 (PT 100) ] + [ Repair: RTX (PT 101, apt=100) ]
155/// ```
156///
157/// The final repair pair is retained only when the primary format also retains Generic NACK.
158///
159/// # Errors
160///
161/// Returns [`RtpNegotiationError::UnsupportedProducerCodec`] when a producer media codec does not
162/// match any router media codec capability, [`RtpNegotiationError::InvalidAptParameter`] when a
163/// RTX codec carries an invalid `apt` parameter, or
164/// [`RtpNegotiationError::MissingAssociatedMediaCodecForRtx`] when a RTX codec references a media
165/// payload that is not part of the negotiated media codec set.
166pub fn derive_consumable_rtp_parameters(
167    producer_parameters: &MediaStream,
168    capabilities: &MediaCapabilities,
169) -> Result<MediaStream, RtpNegotiationError> {
170    // Maps the producer's original primary payload type to the router-visible
171    // payload type chosen for the consumable stream.
172    //
173    // This exists for two reasons:
174    // - RTX `apt` must be rewritten to point at the negotiated primary PT.
175    // - payload-type-bound stream bindings must keep pointing at the negotiated PT,
176    //   not the producer's original PT.
177    let mut producer_to_router_payload_types = Vec::<(PayloadType, PayloadType)>::new();
178    let mut consumable_formats = Vec::new();
179
180    // Primary media codecs are the actual media contract.
181    // If a producer format has no router capability match, the router would not be
182    // able to describe or forward that media in its consumable model, so we reject
183    // the whole producer stream instead of silently dropping the codec.
184    for format in producer_parameters.formats() {
185        // RFC 4588 section 8.1 binds RTX to an already-negotiated primary PT
186        // through `apt`, so media codecs must be matched first.
187        // https://www.rfc-editor.org/rfc/rfc4588.html#section-8.1
188        if format.codec().is_rtx() {
189            continue;
190        }
191        let Some(capability_format) = find_matching_media_capability(format, capabilities) else {
192            // RFC 3264 section 6 only allows formats that both sides support to survive
193            // negotiation, so a producer codec with no router capability match is rejected.
194            // https://www.rfc-editor.org/rfc/rfc3264.html#section-6
195            return Err(RtpNegotiationError::UnsupportedProducerCodec {
196                codec_name: format.codec().as_str().to_owned(),
197                payload_type: format.payload_type(),
198            });
199        };
200        let negotiated_payload_type = negotiated_payload_type(capability_format, format);
201        producer_to_router_payload_types.push((format.payload_type_id(), negotiated_payload_type));
202        let feedback =
203            intersect_feedback(format.rtcp_feedback(), capability_format.rtcp_feedback());
204        consumable_formats.push(format_with_overrides(
205            format,
206            negotiated_payload_type,
207            None,
208            &feedback,
209        ));
210    }
211
212    for format in producer_parameters.formats() {
213        if !format.codec().is_rtx() {
214            continue;
215        }
216        let associated_payload_type = parse_rtx_associated_payload(format)?;
217        let Some(negotiated_associated_payload_type) = producer_to_router_payload_types
218            .iter()
219            .find_map(|(original, mapped)| {
220                (*original == associated_payload_type).then_some(*mapped)
221            })
222        else {
223            // RFC 4588 section 8.1 makes RTX invalid without a negotiated
224            // associated payload type.
225            // https://www.rfc-editor.org/rfc/rfc4588.html#section-8.1
226            return Err(RtpNegotiationError::MissingAssociatedMediaCodecForRtx {
227                payload_type: format.payload_type(),
228                associated_payload_type: associated_payload_type.value(),
229            });
230        };
231        let Some(capability_format) =
232            find_matching_rtx_capability(format, negotiated_associated_payload_type, capabilities)
233        else {
234            // Unlike a primary media codec, RTX is an auxiliary retransmission format.
235            // If the router does not support this RTX pairing, the media stream can still
236            // remain valid without retransmission support, so we drop RTX rather than fail
237            // the whole negotiation.
238            continue;
239        };
240        let negotiated_payload_type = negotiated_payload_type(capability_format, format);
241        let feedback =
242            intersect_feedback(format.rtcp_feedback(), capability_format.rtcp_feedback());
243        consumable_formats.push(format_with_overrides(
244            format,
245            negotiated_payload_type,
246            Some(negotiated_associated_payload_type),
247            &feedback,
248        ));
249    }
250    normalize_nack_rtx(&mut consumable_formats);
251
252    let header_extensions = capabilities
253        .header_extensions()
254        .filter(|extension| {
255            // RFC 8285 negotiates header extensions by common support. Keep only producer-backed
256            // URIs here because the runtime only forwards observed extension values
257            // https://www.rfc-editor.org/rfc/rfc8285.html#section-5
258            producer_parameters
259                .header_extensions()
260                .any(|producer_extension| producer_extension.uri_kind() == extension.uri_kind())
261        })
262        .cloned()
263        .collect::<Vec<_>>();
264    let bindings = producer_parameters
265        .bindings()
266        .cloned()
267        .map(|binding| binding.with_payload_type_mapping(&producer_to_router_payload_types))
268        .collect::<Vec<_>>();
269
270    let mut consumable = MediaStream::new(consumable_formats, header_extensions, bindings);
271    if let Some(mid) = producer_parameters.mid() {
272        consumable = consumable.with_mid(mid);
273    }
274    Ok(consumable)
275}
276
277/// Negotiates the consumer-facing stream from a consumable stream.
278///
279/// Algorithm:
280/// 1. Intersect header extensions.
281/// 2. Derive the BWE feedback policy from surviving extensions.
282/// 3. negotiate primary media codecs first
283/// 4. Admit RTX only when its primary codec survived
284/// 5. Filter bindings so they only reference negotiated payload types.
285///
286/// The output keeps consumable payload types rather than adopting arbitrary
287/// consumer capability PT numbers, because the consumable stream is already the
288/// router's negotiated forwarding model
289///
290/// # Errors
291///
292/// Returns [`RtpNegotiationError::NoCompatibleConsumerCodec`] when no compatible media codec can
293/// be negotiated.
294pub fn negotiate_consumer_rtp_parameters(
295    consumable_parameters: &MediaStream,
296    consumer_capabilities: &MediaCapabilities,
297) -> Result<MediaStream, RtpNegotiationError> {
298    let negotiated_header_extensions =
299        negotiate_header_extensions(consumable_parameters, consumer_capabilities);
300    let feedback_policy = bwe_feedback_policy(&negotiated_header_extensions);
301
302    let mut negotiated_formats = consumable_parameters
303        .formats()
304        .filter_map(|format| {
305            // RFC 4588 section 8.1 requires a surviving primary codec before
306            // RTX can be admitted, so the first pass negotiates primary formats.
307            // https://www.rfc-editor.org/rfc/rfc4588.html#section-8.1
308            if format.codec().is_rtx() {
309                return None;
310            }
311            let capability_format = find_matching_media_capability(format, consumer_capabilities)?;
312            let feedback =
313                intersect_feedback(format.rtcp_feedback(), capability_format.rtcp_feedback());
314            let feedback = apply_bwe_feedback_policy(feedback, feedback_policy);
315            Some(format_with_overrides(
316                format,
317                format.payload_type_id(),
318                None,
319                &feedback,
320            ))
321        })
322        .collect::<Vec<_>>();
323
324    if negotiated_formats.is_empty() {
325        // RFC 3264 section 6 requires at least one mutually acceptable media format for an
326        // accepted stream, so a consumer with no surviving media codec is incompatible.
327        // https://www.rfc-editor.org/rfc/rfc3264.html#section-6
328        return Err(RtpNegotiationError::NoCompatibleConsumerCodec);
329    }
330
331    for format in consumable_parameters.formats() {
332        if !format.codec().is_rtx() {
333            continue;
334        }
335        let Some(capability_format) = find_matching_consumer_rtx_capability(
336            format,
337            &negotiated_formats,
338            consumer_capabilities,
339        ) else {
340            continue;
341        };
342        let feedback =
343            intersect_feedback(format.rtcp_feedback(), capability_format.rtcp_feedback());
344        let feedback = apply_bwe_feedback_policy(feedback, feedback_policy);
345        negotiated_formats.push(format_with_overrides(
346            format,
347            format.payload_type_id(),
348            None,
349            &feedback,
350        ));
351    }
352    normalize_nack_rtx(&mut negotiated_formats);
353
354    let bindings = consumable_parameters
355        .bindings()
356        .filter(|binding| {
357            binding.payload_type_id().is_none_or(|payload_type| {
358                formats_contain_payload_type(&negotiated_formats, payload_type)
359            })
360        })
361        .cloned()
362        .collect::<Vec<_>>();
363
364    let mut negotiated =
365        MediaStream::new(negotiated_formats, negotiated_header_extensions, bindings);
366    if let Some(mid) = consumable_parameters.mid() {
367        negotiated = negotiated.with_mid(mid);
368    }
369    Ok(negotiated)
370}
371
372/// Check whether a consumer capability set can negotiate at least one media codec.
373///
374/// This is the boolean gateway used by router-core when it only needs the final
375/// compatibility result and does not need the fully negotiated RTP output.
376#[must_use]
377pub fn can_consume(
378    consumable_parameters: &MediaStream,
379    consumer_capabilities: &MediaCapabilities,
380) -> bool {
381    negotiate_consumer_rtp_parameters(consumable_parameters, consumer_capabilities).is_ok()
382}
383
384/// Payload-type policy:
385///
386/// - if the capability pins an explicit PT, that PT becomes authoritative in the
387///   negotiated stream
388/// - otherwise we preserve the source PT
389///
390/// This keeps PT assignment under capability control without forcing every
391/// capability entry to hardcode payload types.
392fn negotiated_payload_type(
393    capability_format: &MediaCodecCapability,
394    format: &MediaFormat,
395) -> PayloadType {
396    capability_format
397        .payload_type_id()
398        .unwrap_or(format.payload_type_id())
399}
400
401/// Media capability matching ignores payload type.
402/// PT is negotiated output state, not an identity key for codec compatibility.
403///
404/// Compatibility is instead based on the codec's media kind, codec name,
405/// clock rate, normalized channel count, and codec-specific critical fmtp
406/// parameters.
407fn find_matching_media_capability<'a>(
408    format: &MediaFormat,
409    capabilities: &'a MediaCapabilities,
410) -> Option<&'a MediaCodecCapability> {
411    capabilities.codecs().find(|capability_format| {
412        !capability_format.codec().is_rtx()
413            && codec_match_ignoring_payload_type(format, capability_format)
414    })
415}
416
417/// RTX matching has one extra constraint beyond ordinary codec matching:
418///
419/// the router RTX capability must be associated with the already negotiated
420/// primary payload type, because RFC 4588 binds RTX to a specific primary PT
421/// through `apt`.
422/// <https://www.rfc-editor.org/rfc/rfc4588.html#section-8.1>
423fn find_matching_rtx_capability<'a>(
424    format: &MediaFormat,
425    negotiated_associated_payload_type: PayloadType,
426    capabilities: &'a MediaCapabilities,
427) -> Option<&'a MediaCodecCapability> {
428    capabilities.codecs().find(|capability_format| {
429        capability_format.codec().is_rtx()
430            && codec_match_ignoring_payload_type(format, capability_format)
431            && capability_format.rtx_associated_payload_type_id()
432                == Some(negotiated_associated_payload_type)
433    })
434}
435
436/// Consumer-side RTX matching works against the already-consumable stream.
437/// At this point the stream's `apt` must refer to a primary PT that survived
438/// consumer negotiation, otherwise forwarding RTX would create an orphan repair
439/// stream with no valid primary target
440fn find_matching_consumer_rtx_capability<'a>(
441    format: &MediaFormat,
442    negotiated_formats: &[MediaFormat],
443    capabilities: &'a MediaCapabilities,
444) -> Option<&'a MediaCodecCapability> {
445    let associated_payload_type = parse_rtx_associated_payload(format).ok()?;
446    // RFC 4588 section 8.1 ties each RTX format to one negotiated primary PT.
447    // https://www.rfc-editor.org/rfc/rfc4588.html#section-8.1
448    if !formats_contain_primary_payload_type(negotiated_formats, associated_payload_type) {
449        return None;
450    }
451    capabilities.codecs().find(|capability_format| {
452        capability_format.codec().is_rtx()
453            && codec_match_ignoring_payload_type(format, capability_format)
454            && capability_format.rtx_associated_payload_type_id() == Some(associated_payload_type)
455    })
456}
457
458/// Returns whether two formats describe the same codec configuration, ignoring PT
459///
460/// In RTP/SDP, payload type is only a local number bound to a codec description;
461/// it is not itself the codec identity. Compatibility therefore comes from the
462/// semantic codec fields and any fmtp parameters that change the wire format
463fn codec_match_ignoring_payload_type(
464    format: &MediaFormat,
465    capability_format: &MediaCodecCapability,
466) -> bool {
467    if format.media_kind() != capability_format.media_kind()
468        || format.codec() != capability_format.codec()
469        || format.clock_rate() != capability_format.clock_rate()
470    {
471        return false;
472    }
473    if normalized_channels(format.media_kind(), format.channels())
474        != normalized_channels(capability_format.media_kind(), capability_format.channels())
475    {
476        return false;
477    }
478    critical_codec_settings_match(format, capability_format)
479}
480
481/// Only settings that actually affect wire compatibility are treated as hard
482/// negotiation keys here.
483///
484/// Receiver preferences or advisory parameters are not all compatibility
485/// blockers because this module is trying to answer "can the
486/// formats interoperate?" rather than "are all local preferences identical?"
487fn critical_codec_settings_match(
488    format: &MediaFormat,
489    capability_format: &MediaCodecCapability,
490) -> bool {
491    match format.codec() {
492        MediaCodec::H264 => h264_critical_settings_match(format, capability_format),
493        MediaCodec::Vp9 => vp9_critical_settings_match(format, capability_format),
494        _ => true,
495    }
496}
497
498/// `packetization-mode` is a hard compatibility key.
499/// Different packetization modes describe different RTP packetization behaviour,
500/// so mismatches are not just preferences
501fn h264_critical_settings_match(
502    format: &MediaFormat,
503    capability_format: &MediaCodecCapability,
504) -> bool {
505    let Some(format_packetization_mode) = h264_packetization_mode(format.settings()) else {
506        return false;
507    };
508    let Some(capability_packetization_mode) = h264_packetization_mode(capability_format.settings())
509    else {
510        return false;
511    };
512    // RFC 6184 section 8.2.2 requires packetization-mode compatibility, mismatched packetization
513    // modes describe different wire behaviors and are therefore rejected.
514    // https://www.rfc-editor.org/rfc/rfc6184.html#section-8.2.2
515    if format_packetization_mode != capability_packetization_mode {
516        return false;
517    }
518    let format_profile_level_id = format
519        .settings()
520        .find_map(|setting| match setting {
521            CodecSetting::H264ProfileLevelId(profile_level_id) => Some(profile_level_id.as_str()),
522            _ => None,
523        })
524        .unwrap_or(rfc_rtp::fmtp::H264_DEFAULT_PROFILE_LEVEL_ID);
525    let capability_profile_level_id = capability_format
526        .settings()
527        .find_map(|setting| match setting {
528            CodecSetting::H264ProfileLevelId(profile_level_id) => Some(profile_level_id.as_str()),
529            _ => None,
530        })
531        .unwrap_or(rfc_rtp::fmtp::H264_DEFAULT_PROFILE_LEVEL_ID);
532    let Some(parsed_format_profile_level_id) =
533        rfc_rtp::h264::ProfileLevelId::parse(format_profile_level_id)
534    else {
535        return false;
536    };
537    let Some(parsed_capability_profile_level_id) =
538        rfc_rtp::h264::ProfileLevelId::parse(capability_profile_level_id)
539    else {
540        return false;
541    };
542    parsed_format_profile_level_id.profile() == parsed_capability_profile_level_id.profile()
543        && parsed_format_profile_level_id.level() <= parsed_capability_profile_level_id.level()
544}
545
546fn h264_packetization_mode<'a>(
547    settings: impl Iterator<Item = &'a CodecSetting>,
548) -> Option<rfc_rtp::h264::PacketizationMode> {
549    for setting in settings {
550        match setting {
551            CodecSetting::H264PacketizationMode(mode) => return Some(*mode),
552            CodecSetting::Other { key, .. } if key == rfc_rtp::fmtp::H264_PACKETIZATION_MODE => {
553                return None;
554            }
555            _ => {}
556        }
557    }
558    rfc_rtp::h264::PacketizationMode::from_fmtp_value(
559        rfc_rtp::fmtp::H264_DEFAULT_PACKETIZATION_MODE,
560    )
561}
562
563fn vp9_critical_settings_match(
564    format: &MediaFormat,
565    capability_format: &MediaCodecCapability,
566) -> bool {
567    effective_vp9_profile_id(format.settings())
568        .zip(effective_vp9_profile_id(capability_format.settings()))
569        .is_some_and(|(format_profile, capability_profile)| format_profile == capability_profile)
570}
571
572fn effective_vp9_profile_id<'a>(
573    settings: impl Iterator<Item = &'a CodecSetting>,
574) -> Option<rfc_rtp::Vp9ProfileId> {
575    let mut profile_id = None;
576    for setting in settings {
577        match setting {
578            CodecSetting::Vp9ProfileId(value) => profile_id = Some(*value),
579            CodecSetting::Other { key, .. } if key == rfc_rtp::fmtp::VP9_PROFILE_ID => return None,
580            _ => {}
581        }
582    }
583    Some(profile_id.unwrap_or(rfc_rtp::fmtp::VP9_DEFAULT_PROFILE_ID))
584}
585
586/// `apt` is not optional metadata for RTX.
587/// It is the linkage that says which primary payload type this repair stream
588/// protects. Without it, the RTX format is structurally invalid for negotiation.
589fn parse_rtx_associated_payload(format: &MediaFormat) -> Result<PayloadType, RtpNegotiationError> {
590    format.rtx_associated_payload_type_id().ok_or_else(|| {
591        RtpNegotiationError::InvalidAptParameter {
592            codec_name: format.codec().as_str().to_owned(),
593            payload_type: format.payload_type(),
594        }
595    })
596}
597
598/// Rebuild the format from the source while applying negotiated overrides.
599///
600/// We copy all original codec settings except RTX `apt`, because `apt` may need
601/// to be rewritten after payload-type remapping. RTCP feedback is also rebuilt
602/// from the negotiated intersection rather than just copied, so the result
603/// only advertises mutually supported feedback mechanisms.
604fn format_with_overrides(
605    source: &MediaFormat,
606    payload_type: PayloadType,
607    apt_override: Option<PayloadType>,
608    feedback: &[RtcpFeedback],
609) -> MediaFormat {
610    let mut format = MediaFormat::new(
611        source.media_kind(),
612        source.codec().clone(),
613        payload_type,
614        source.clock_rate(),
615    );
616    if let Some(channels) = source.channels() {
617        format = format.with_channels(channels);
618    }
619    for setting in source
620        .settings()
621        .filter(|setting| !matches!(setting, CodecSetting::RtxAssociation(_)))
622        .cloned()
623    {
624        format = format.with_setting(setting);
625    }
626    if let Some(apt) = apt_override {
627        format = format.with_setting(CodecSetting::RtxAssociation(apt));
628    } else if let Some(apt) = source.rtx_associated_payload_type_id() {
629        format = format.with_setting(CodecSetting::RtxAssociation(apt));
630    }
631    for entry in feedback {
632        format = format.with_rtcp_feedback(entry.clone());
633    }
634    format
635}
636
637/// RTCP feedback is negotiated by common support, not by union.
638/// Advertising feedback that only one side supports would let later code assume
639/// a control signal is usable when the peer never negotiated it.
640/// <https://www.rfc-editor.org/rfc/rfc4585.html#section-4.2>
641fn intersect_feedback<'a>(
642    format_feedback: impl Iterator<Item = &'a RtcpFeedback>,
643    capability_feedback: impl Iterator<Item = &'a RtcpFeedback>,
644) -> Vec<RtcpFeedback> {
645    let capability_feedback = capability_feedback.cloned().collect::<Vec<_>>();
646    format_feedback
647        .filter(|feedback| capability_feedback.contains(feedback))
648        .cloned()
649        .collect()
650}
651
652fn normalize_nack_rtx(formats: &mut Vec<MediaFormat>) {
653    // RFC 4585 negotiates Generic NACK per format and RFC 4588 links RTX through
654    // `apt`. O-SFU policy exposes repair only as a complete pair. It keeps the
655    // first matching RTX, drops duplicates and orphans then removes Generic
656    // NACK when no repair remains.
657    // https://www.rfc-editor.org/rfc/rfc4585.html#section-4.2
658    // https://www.rfc-editor.org/rfc/rfc4588.html#section-8.1
659    let nack_payload_types = formats
660        .iter()
661        .filter(|format| !format.codec().is_rtx() && has_generic_nack(format))
662        .map(MediaFormat::payload_type_id)
663        .collect::<HashSet<_>>();
664    let mut paired_payload_types = HashSet::new();
665    formats.retain(|format| {
666        !format.codec().is_rtx()
667            || format.rtx_associated_payload_type_id().is_some_and(|apt| {
668                nack_payload_types.contains(&apt) && paired_payload_types.insert(apt)
669            })
670    });
671
672    for format in formats.iter_mut() {
673        if format.codec().is_rtx()
674            || !has_generic_nack(format)
675            || paired_payload_types.contains(&format.payload_type_id())
676        {
677            continue;
678        }
679        let feedback = format
680            .rtcp_feedback()
681            .filter(|feedback| !matches!(feedback.kind(), RtcpFeedbackKind::Nack))
682            .cloned()
683            .collect::<Vec<_>>();
684        *format = format_with_overrides(format, format.payload_type_id(), None, &feedback);
685    }
686}
687
688fn has_generic_nack(format: &MediaFormat) -> bool {
689    format
690        .rtcp_feedback()
691        .any(|feedback| matches!(feedback.kind(), RtcpFeedbackKind::Nack))
692}
693
694/// Channel count is only part of codec identity for audio.
695/// Video formats do not use RTP channel count semantics in this model, so we
696/// normalize all video channel counts away to avoid spurious mismatches.
697fn normalized_channels(media_kind: MediaKind, channels: Option<u16>) -> Option<u16> {
698    if media_kind == MediaKind::Audio {
699        Some(channels.unwrap_or(1))
700    } else {
701        None
702    }
703}
704
705/// This is a URI-level capability intersection only.
706///
707/// It does NOT perform full SDP extmap negotiation:
708/// - no direction filtering
709/// - no id collision checks
710/// - no BUNDLE-wide extmap consistency checks
711///
712/// Those rules belong to the SDP/signaling edge. This helper only answers
713/// whether the typed RTP model should keep an extension URI at all.
714fn negotiate_header_extensions(
715    consumable_parameters: &MediaStream,
716    consumer_capabilities: &MediaCapabilities,
717) -> Vec<HeaderExtension> {
718    consumable_parameters
719        .header_extensions()
720        // RFC 8285 extmaps are negotiated by common support. This helper keeps the
721        // router model at URI-intersection scope and leaves direction/id validation to the SDP edge.
722        // https://www.rfc-editor.org/rfc/rfc8285.html#section-5
723        .filter(|extension| {
724            consumer_capabilities
725                .header_extensions()
726                .any(|supported| supported.uri_kind() == extension.uri_kind())
727        })
728        .cloned()
729        .collect()
730}
731
732fn formats_contain_payload_type(formats: &[MediaFormat], payload_type: PayloadType) -> bool {
733    formats
734        .iter()
735        .any(|format| format.payload_type_id() == payload_type)
736}
737
738fn formats_contain_primary_payload_type(
739    formats: &[MediaFormat],
740    payload_type: PayloadType,
741) -> bool {
742    formats
743        .iter()
744        .any(|format| !format.codec().is_rtx() && format.payload_type_id() == payload_type)
745}
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq)]
748enum BweFeedbackPolicy {
749    PreferTransportCc,
750    PreferGoogRemb,
751    DisableBoth,
752}
753
754/// Selects a local forwarding policy for mutually exclusive bandwidth-estimation
755/// feedback families.
756///
757/// Resolves conflicting congestion control feedback modes by removing feedback only:
758/// - if transport-wide CC is available, prefer `transport-cc` and strip `goog-remb`
759/// - otherwise, if abs-send-time is available, prefer `goog-remb` and strip `transport-cc`
760/// - otherwise strip both feedback families
761///
762/// ```text
763///                Negotiated Header Extensions
764///                             |
765///            +----------------+----------------+
766///            |                                 |
767///   Contains TWCC extension?          Otherwise abs-send-time?
768///            |                                 |
769///            v (yes)                           v (yes)
770///  [ PreferTransportCc ]              [ PreferGoogRemb ]
771///            |                                 |
772///            v                                 v
773///  - Keep `transport-cc` feedback     - Keep `goog-remb` feedback
774///  - Strip conflicting `goog-remb`    - Strip `transport-cc`
775/// ```
776///
777/// If neither extension is present, [`BweFeedbackPolicy::DisableBoth`] strips both families.
778fn bwe_feedback_policy(header_extensions: &[HeaderExtension]) -> BweFeedbackPolicy {
779    if header_extensions.iter().any(|extension| {
780        matches!(
781            extension.uri_kind(),
782            HeaderExtensionUri::TransportWideCcDraft01
783        )
784    }) {
785        return BweFeedbackPolicy::PreferTransportCc;
786    }
787    if header_extensions
788        .iter()
789        .any(|extension| matches!(extension.uri_kind(), HeaderExtensionUri::AbsSendTime))
790    {
791        return BweFeedbackPolicy::PreferGoogRemb;
792    }
793    BweFeedbackPolicy::DisableBoth
794}
795
796/// Negotiation may leave both transport-cc and goog-remb present in the raw
797/// RTCP feedback intersection. We filter here so downstream sender logic sees one
798/// coherent bandwidth-estimation mode instead of multiple competing ones.
799fn apply_bwe_feedback_policy(
800    feedback: Vec<RtcpFeedback>,
801    policy: BweFeedbackPolicy,
802) -> Vec<RtcpFeedback> {
803    feedback
804        .into_iter()
805        .filter(|entry| match policy {
806            BweFeedbackPolicy::PreferTransportCc => {
807                !matches!(entry.kind(), RtcpFeedbackKind::GoogRemb)
808            }
809            BweFeedbackPolicy::PreferGoogRemb => {
810                !matches!(entry.kind(), RtcpFeedbackKind::TransportCc)
811            }
812            BweFeedbackPolicy::DisableBoth => !matches!(
813                entry.kind(),
814                RtcpFeedbackKind::TransportCc | RtcpFeedbackKind::GoogRemb
815            ),
816        })
817        .collect()
818}