Skip to main content

arcly_stream/protocol/webrtc/
mod.rs

1//! WebRTC WHIP/WHEP ingest & egress signaling with a pluggable crypto transport
2//! (feature `webrtc`).
3//!
4//! This module ships the parts of WebRTC that are *protocol logic* — and which
5//! can therefore live in a `#![forbid(unsafe_code)]`, dependency-light kernel:
6//!
7//! - **WHIP ingest** ([`WhipEndpoint`]) and **WHEP egress** ([`WhepEndpoint`]):
8//!   the HTTP-driven SDP offer/answer exchange and resource lifecycle. The host
9//!   wires these calls into *its own* HTTP server (Axum, Hyper, …) — the kernel
10//!   never imposes a web framework.
11//! - **SDP munging** ([`sdp`]): parse a browser offer, emit a compatible answer.
12//! - **RTP routing**: incoming (decrypted) RTP feeds the shared
13//!   [`H264Depacketizer`] onto the bus (ingest); outgoing frames are framed by
14//!   [`RtpPacketizer`] and sent over the
15//!   transport (egress).
16//! - **RTCP feedback** ([`rtcp`]): build PLI/FIR keyframe requests to send back
17//!   upstream when a late subscriber needs an IDR.
18//! - **Simulcast** ([`sdp`] + RID RTP header extension): a WHIP publisher's
19//!   `a=simulcast`/`a=rid` layers are negotiated and each RID is demultiplexed
20//!   onto its own stream (base layer → the requested key, others → `<stream>~<rid>`).
21//! - **Trickle ICE** ([`ice`]): parse a `PATCH` candidate fragment and feed each
22//!   candidate to the transport via [`DtlsSrtpTransport::add_remote_candidate`].
23//! - **Data channels**: SDP `m=application` negotiation plus a `recv_data`/
24//!   `send_data` seam on the transport (the SCTP-over-DTLS bytes are the
25//!   transport's job, as with media).
26//!
27//! # The crypto seam
28//!
29//! A *working* WebRTC connection needs DTLS, SRTP, and ICE. Those cannot be
30//! hand-rolled correctly without a vetted crypto stack, so they are **not**
31//! implemented here. Instead the host supplies them through the
32//! [`DtlsSrtpTransport`] trait — backed by a crate such as `str0m` or
33//! `webrtc-rs` — and this module drives the media plane over it. The kernel thus
34//! stays crypto-free and `unsafe`-free while remaining fully WebRTC-capable when
35//! a transport is plugged in.
36//!
37//! This is an honest boundary: the signaling and media routing are real and
38//! tested; the encrypted transport is an injected dependency, by design.
39
40pub mod ice;
41pub mod room;
42pub mod rtcp;
43pub mod sdp;
44
45pub use ice::{parse_trickle, IceCandidate};
46pub use room::{DominantSpeaker, Room};
47pub use sdp::{MediaDirection, SdpAnswerParams, SdpOffer};
48
49use crate::bus::PlaybackRegistry;
50use crate::inbound::{IngestContext, PublishSession};
51#[cfg(feature = "codec-av1")]
52use crate::protocol::rtp::Av1Packetizer;
53use crate::protocol::rtp::{
54    H264Depacketizer, OpusPacketizer, RtpHeader, RtpPacketizer, Vp9Packetizer,
55};
56use crate::{CodecId, MediaFrame, Result, StreamKey};
57use async_trait::async_trait;
58use std::sync::Arc;
59
60/// A snapshot of one peer connection's quality, surfaced from the transport for
61/// host metrics (Prometheus, dashboards) and Phase-1/2 debugging.
62///
63/// All fields are `Option` because a fresh connection may not have produced an
64/// estimate or a feedback report yet.
65#[derive(Debug, Clone, Default, PartialEq)]
66pub struct PeerStats {
67    /// Egress bandwidth estimate in bits/sec (TWCC/REMB), as in
68    /// [`estimated_bitrate`](DtlsSrtpTransport::estimated_bitrate).
69    pub estimated_bitrate_bps: Option<u64>,
70    /// Round-trip time in milliseconds, from the latest RTCP receiver report.
71    pub rtt_ms: Option<f32>,
72    /// Egress packet loss fraction over the last second (0.0–1.0).
73    pub egress_loss: Option<f32>,
74}
75
76/// The host-supplied DTLS-SRTP transport for one peer connection.
77///
78/// Implement this over a vetted WebRTC crypto stack. The kernel calls it to pull
79/// decrypted RTP and to push RTCP feedback; it never sees keys or handshakes.
80#[async_trait]
81pub trait DtlsSrtpTransport: Send + Sync {
82    /// The DTLS certificate fingerprint (`sha-256 AA:BB:…`) to advertise in the
83    /// SDP answer's `a=fingerprint` line.
84    fn fingerprint(&self) -> String;
85
86    /// The ICE ufrag/pwd pair to advertise in the SDP answer.
87    ///
88    /// Per RFC 5245 these have length limits browsers enforce: the **ufrag** must
89    /// be 4–256 characters and the **pwd** 22–256 characters. A pwd shorter than
90    /// 22 chars makes `setRemoteDescription` reject the answer with
91    /// `Invalid ICE parameters`.
92    fn ice_credentials(&self) -> (String, String);
93
94    /// Receive the next decrypted RTP packet, or `None` when the peer closes.
95    /// Used by the WHIP **ingest** path; a send-only WHEP transport may leave the
96    /// default (returns `None` immediately).
97    async fn recv_rtp(&self) -> Option<Vec<u8>> {
98        None
99    }
100
101    /// Send an RTP packet to the peer (SRTP-encrypted by the transport). Used by
102    /// the WHEP **egress** path.
103    async fn send_rtp(&self, _packet: &[u8]) -> Result<()> {
104        Ok(())
105    }
106
107    /// Send an RTCP packet (e.g. a PLI/FIR built by [`rtcp`]) back to the peer.
108    async fn send_rtcp(&self, packet: &[u8]) -> Result<()>;
109
110    /// Receive the next (decrypted) RTCP compound packet from the peer, or `None`
111    /// when the peer closes / the transport carries no RTCP.
112    ///
113    /// Used by the WHEP **egress** path to read viewer feedback — PLI/FIR (request
114    /// a fresh keyframe), generic NACK (loss to retransmit), and Receiver Reports
115    /// (loss/jitter for QoS and bandwidth estimation), decoded via
116    /// [`rtcp::parse_compound`]. The default returns `None`, so a transport with no
117    /// upstream RTCP keeps compiling.
118    async fn recv_rtcp(&self) -> Option<Vec<u8>> {
119        None
120    }
121
122    /// The transport's current egress **bandwidth estimate** in bits/sec, or
123    /// `None` when the transport does not estimate bandwidth.
124    ///
125    /// A WebRTC stack (e.g. str0m) derives this from TWCC/REMB feedback far more
126    /// accurately than the crypto-free kernel could from raw RTCP, so the kernel
127    /// reads it through this seam instead of re-deriving it. It is the per-viewer
128    /// input to adaptive layer selection (Phase 2). The default returns `None`.
129    fn estimated_bitrate(&self) -> Option<u64> {
130        None
131    }
132
133    /// A snapshot of this peer's connection quality for metrics/observability, or
134    /// `None` when the transport does not collect stats. See [`PeerStats`].
135    fn peer_stats(&self) -> Option<PeerStats> {
136        None
137    }
138
139    /// Add a remote ICE candidate trickled in after the initial answer (a WHIP/
140    /// WHEP `PATCH`; see [`ice`]). The `candidate` is the SDP attribute value
141    /// (everything after `a=candidate:`). The default is a no-op for transports
142    /// that gather all candidates up front (non-trickle).
143    async fn add_remote_candidate(&self, _candidate: &str) -> Result<()> {
144        Ok(())
145    }
146
147    /// Receive the next application **data-channel** message as `(label, bytes)`,
148    /// or `None` when no data channel is open / the peer closed it. The default
149    /// returns `None` (no data-channel support).
150    async fn recv_data(&self) -> Option<(String, Vec<u8>)> {
151        None
152    }
153
154    /// Send an application **data-channel** message on the channel named `label`.
155    /// The default is a no-op (no data-channel support).
156    async fn send_data(&self, _label: &str, _data: &[u8]) -> Result<()> {
157        Ok(())
158    }
159
160    /// Produce the SDP **answer** for the raw `offer_sdp` with the given media
161    /// `direction`.
162    ///
163    /// This is the seam's SDP hook: the default builds a minimal answer from the
164    /// transport's [`fingerprint`](Self::fingerprint) and
165    /// [`ice_credentials`](Self::ice_credentials) — correct for the kernel's
166    /// in-crate SDP. A transport that owns SDP generation itself (e.g. a str0m
167    /// backend) overrides this to parse the offer and return its own complete
168    /// answer, so the kernel never imposes its SDP shape on a real WebRTC stack.
169    fn answer(&self, offer_sdp: &str, direction: MediaDirection) -> String {
170        let Some(offer) = SdpOffer::parse(offer_sdp) else {
171            return String::new();
172        };
173        let (ice_ufrag, ice_pwd) = self.ice_credentials();
174        sdp::build_answer_directed(
175            &offer,
176            &SdpAnswerParams {
177                fingerprint: self.fingerprint(),
178                ice_ufrag,
179                ice_pwd,
180            },
181            direction,
182        )
183    }
184}
185
186/// A WHIP/WHEP signaling endpoint the host drives from its HTTP layer.
187///
188/// `POST` of an SDP offer → [`accept_offer`](Self::accept_offer) returns the SDP
189/// answer to write back with `201 Created`. The returned [`WhipResource`] is the
190/// handle the host stores (keyed by the `Location` URL) and later
191/// [`close`](WhipResource::close)s on `DELETE`.
192#[derive(Clone)]
193pub struct WhipEndpoint {
194    ctx: IngestContext,
195}
196
197impl WhipEndpoint {
198    /// Build an endpoint that publishes ingested media through `ctx`.
199    pub fn new(ctx: IngestContext) -> Self {
200        Self { ctx }
201    }
202
203    /// Handle a WHIP `POST`: validate the offer, mint the answer from the
204    /// transport's credentials, and return the resource handle plus the answer
205    /// SDP. The host then runs [`WhipResource::pump`] (typically `tokio::spawn`)
206    /// to move media for the connection's lifetime.
207    pub fn accept_offer(
208        &self,
209        offer_sdp: &str,
210        key: StreamKey,
211        transport: std::sync::Arc<dyn DtlsSrtpTransport>,
212    ) -> Result<(WhipResource, String)> {
213        // Validate the offer parses; the transport owns answer generation (see
214        // `DtlsSrtpTransport::answer`) and gets the raw SDP.
215        let offer = SdpOffer::parse(offer_sdp)
216            .ok_or_else(|| crate::StreamError::protocol("malformed SDP offer"))?;
217        // WHIP ingest: the publisher sends, we receive → recvonly answer.
218        let answer = transport.answer(offer_sdp, MediaDirection::RecvOnly);
219        let resource = WhipResource {
220            ctx: self.ctx.clone(),
221            key,
222            transport,
223            video_pt: offer.payload_type,
224            audio_pt: offer.audio_payload_type,
225            rid_ext_id: offer.rid_ext_id,
226            simulcast_rids: offer.simulcast_rids,
227        };
228        Ok((resource, answer))
229    }
230}
231
232/// An accepted WHIP connection: pumps decrypted RTP onto the bus until the peer
233/// or transport closes.
234pub struct WhipResource {
235    ctx: IngestContext,
236    key: StreamKey,
237    transport: std::sync::Arc<dyn DtlsSrtpTransport>,
238    /// Negotiated H.264 video payload type (RTP packets carrying it are
239    /// depacketized into access units).
240    video_pt: u8,
241    /// Negotiated Opus audio payload type, if the publisher offered audio — RTP
242    /// packets carrying it are published as Opus audio frames directly.
243    audio_pt: Option<u8>,
244    /// RTP header-extension id carrying the RID (simulcast layer label), if the
245    /// offer negotiated simulcast.
246    rid_ext_id: Option<u8>,
247    /// Simulcast layers offered (rid identifiers, declared order). The first is
248    /// the *base* layer published to the requested key; others go to a
249    /// `<stream>~<rid>` key so a subscriber can pick a layer.
250    simulcast_rids: Vec<String>,
251}
252
253impl WhipResource {
254    /// Drive the media plane until the transport yields `None` (peer gone).
255    ///
256    /// A simulcast publisher (multiple RID layers negotiated) is demultiplexed
257    /// per layer onto distinct streams; otherwise a single stream is routed by
258    /// payload type — H.264 depacketized into access units, Opus audio published
259    /// frame-for-packet.
260    pub async fn pump(self) -> Result<()> {
261        match self.rid_ext_id {
262            Some(ext) if self.simulcast_rids.len() > 1 => self.pump_simulcast(ext).await,
263            _ => self.pump_single().await,
264        }
265    }
266
267    /// The non-simulcast path: one publish session routed by payload type.
268    async fn pump_single(self) -> Result<()> {
269        let session: PublishSession = self.ctx.open_publish(self.key.clone()).await?;
270        let handle = session.handle().clone();
271        let mut depack = H264Depacketizer::new();
272        let mut needs_keyframe = true;
273        // Most recent media SSRC, so a downstream-relayed keyframe request targets
274        // the right stream in the PLI we send the publisher.
275        let mut last_ssrc = 0u32;
276
277        loop {
278            let pkt = tokio::select! {
279                pkt = self.transport.recv_rtp() => match pkt {
280                    Some(p) => p,
281                    None => break,
282                },
283                // A playback consumer (e.g. a WHEP viewer) asked for a keyframe:
284                // relay it upstream as a PLI so the publisher emits a fresh IDR.
285                _ = handle.keyframe_requested() => {
286                    let pli = rtcp::build_pli(0, last_ssrc);
287                    let _ = self.transport.send_rtcp(&pli).await;
288                    continue;
289                }
290            };
291            let Some(header) = RtpHeader::parse(&pkt) else {
292                continue;
293            };
294            last_ssrc = header.ssrc;
295            let payload = &pkt[header.payload_offset..];
296
297            // Audio: one Opus packet per RTP payload (no depacketization). The
298            // Opus RTP clock is 48 kHz, so PTS(ms) = timestamp / 48.
299            if self.audio_pt == Some(header.payload_type) {
300                if !payload.is_empty() {
301                    let pts = (header.timestamp / 48) as i64;
302                    let data = bytes::Bytes::copy_from_slice(payload);
303                    let frame = MediaFrame::new_audio(pts, data, CodecId::Opus);
304                    let _ = session.publish_frame(frame)?;
305                }
306                continue;
307            }
308
309            // Video (default): everything else is treated as the H.264 stream.
310            let _ = self.video_pt; // negotiated PT (routing is by elimination here)
311            match depack.push(payload, header.marker, header.timestamp, header.sequence) {
312                Ok(Some(au)) => {
313                    needs_keyframe = false;
314                    let pts = (au.timestamp / 90) as i64;
315                    let frame =
316                        MediaFrame::new_video(pts, pts, au.data, CodecId::H264, au.keyframe);
317                    let _ = session.publish_frame(frame)?;
318                }
319                Ok(None) => {}
320                Err(_) => {
321                    // Loss/gap: ask the sender for a fresh IDR via RTCP PLI.
322                    needs_keyframe = true;
323                }
324            }
325            if needs_keyframe {
326                let pli = rtcp::build_pli(0, header.ssrc);
327                let _ = self.transport.send_rtcp(&pli).await;
328            }
329        }
330
331        session.finish().await
332    }
333
334    /// The simulcast path: each RID layer is depacketized independently and
335    /// published to its own stream — the base (first-declared) layer to the
336    /// requested key, the rest to `<stream>~<rid>` so a viewer can select one.
337    /// This is the SFU's RID-routing core; layer *selection*/forwarding policy
338    /// then lives in the consumer that subscribes to these per-layer streams.
339    async fn pump_simulcast(self, rid_ext: u8) -> Result<()> {
340        use std::collections::HashMap;
341        struct Layer {
342            session: PublishSession,
343            depack: H264Depacketizer,
344            needs_keyframe: bool,
345        }
346        let base = self.simulcast_rids[0].clone();
347        let mut layers: HashMap<String, Layer> = HashMap::new();
348
349        while let Some(pkt) = self.transport.recv_rtp().await {
350            let Some(header) = RtpHeader::parse(&pkt) else {
351                continue;
352            };
353            // Label the packet by its RID extension; packets without one fall to
354            // the base layer (some senders omit the rid on the base encoding).
355            let rid = crate::protocol::rtp::rtp_extension_value(&pkt, rid_ext)
356                .and_then(|b| std::str::from_utf8(b).ok())
357                .map(str::to_owned)
358                .unwrap_or_else(|| base.clone());
359            if !self.simulcast_rids.contains(&rid) {
360                continue; // unknown layer label
361            }
362
363            if !layers.contains_key(&rid) {
364                let key = self.layer_key(&rid, &base);
365                let session = self.ctx.open_publish(key).await?;
366                layers.insert(
367                    rid.clone(),
368                    Layer {
369                        session,
370                        depack: H264Depacketizer::new(),
371                        needs_keyframe: true,
372                    },
373                );
374            }
375            let layer = layers.get_mut(&rid).unwrap();
376            let payload = &pkt[header.payload_offset..];
377            match layer
378                .depack
379                .push(payload, header.marker, header.timestamp, header.sequence)
380            {
381                Ok(Some(au)) => {
382                    layer.needs_keyframe = false;
383                    let pts = (au.timestamp / 90) as i64;
384                    let frame =
385                        MediaFrame::new_video(pts, pts, au.data, CodecId::H264, au.keyframe);
386                    let _ = layer.session.publish_frame(frame)?;
387                }
388                Ok(None) => {}
389                Err(_) => layer.needs_keyframe = true,
390            }
391            if layer.needs_keyframe {
392                let pli = rtcp::build_pli(0, header.ssrc);
393                let _ = self.transport.send_rtcp(&pli).await;
394            }
395        }
396
397        for (_, layer) in layers {
398            layer.session.finish().await?;
399        }
400        Ok(())
401    }
402
403    /// The stream key a simulcast layer publishes to: the requested key for the
404    /// base layer, `<stream>~<rid>` for the others.
405    fn layer_key(&self, rid: &str, base: &str) -> StreamKey {
406        if rid == base {
407            self.key.clone()
408        } else {
409            self.key.layer(rid)
410        }
411    }
412
413    /// Tear the resource down on a WHIP `DELETE` without pumping media.
414    pub async fn close(self) -> Result<()> {
415        Ok(())
416    }
417}
418
419/// A WHEP (egress) signaling endpoint — the playback counterpart to
420/// [`WhipEndpoint`].
421///
422/// A viewer `POST`s an SDP offer; [`accept_offer`](Self::accept_offer) returns a
423/// `sendonly` answer and a [`WhepResource`]. The host then runs
424/// [`WhepResource::pump`], which subscribes to the requested live stream,
425/// packetizes each H.264 access unit into RTP, and sends it over the peer's
426/// [`DtlsSrtpTransport`] — sub-second WebRTC playback.
427#[derive(Clone)]
428pub struct WhepEndpoint {
429    playback: Arc<dyn PlaybackRegistry>,
430    /// Egress gate (per-app toggle + play token). `None` = open playback, the
431    /// same permit-all default as RTSP `PLAY` and SRT `m=request`.
432    gate: Option<crate::auth::EgressGate>,
433}
434
435impl WhepEndpoint {
436    /// Build an endpoint that serves media from `playback` (e.g. an `Arc<Engine>`).
437    pub fn new(playback: Arc<dyn PlaybackRegistry>) -> Self {
438        Self {
439            playback,
440            gate: None,
441        }
442    }
443
444    /// Gate playback (egress) requests through `gate` (per-app toggle + play
445    /// token), mirroring [`RtspServer::with_gate`](crate::protocol::rtsp::RtspServer::with_gate)
446    /// and the SRT egress gate. Consulted by [`accept_offer_gated`](Self::accept_offer_gated).
447    pub fn with_gate(mut self, gate: crate::auth::EgressGate) -> Self {
448        self.gate = Some(gate);
449        self
450    }
451
452    /// Like [`accept_offer`](Self::accept_offer), but first consults the egress
453    /// gate (if installed via [`with_gate`](Self::with_gate)) with `token` — the
454    /// play token the viewer presented (e.g. `auth::token_from_query` over the
455    /// WHEP POST URL). A denied request returns
456    /// [`Unauthorized`](crate::StreamError::Unauthorized); with no gate, egress is
457    /// open. Hosts that authorize at the HTTP layer can keep using `accept_offer`.
458    pub async fn accept_offer_gated(
459        &self,
460        offer_sdp: &str,
461        key: StreamKey,
462        token: Option<String>,
463        peer: Option<std::net::SocketAddr>,
464        transport: Arc<dyn DtlsSrtpTransport>,
465    ) -> Result<(WhepResource, String)> {
466        if let Some(gate) = self.gate.as_ref() {
467            if !gate(key.clone(), token, peer).await {
468                return Err(crate::StreamError::Unauthorized(
469                    "whep egress denied by gate".into(),
470                ));
471            }
472        }
473        self.accept_offer(offer_sdp, key, transport)
474    }
475
476    /// Handle a WHEP `POST`: validate the offer and mint a `sendonly` answer.
477    /// Returns the resource handle (to `pump`) and the answer SDP.
478    ///
479    /// This does **not** consult the egress gate; gate playback either at the
480    /// HTTP host layer or via [`accept_offer_gated`](Self::accept_offer_gated).
481    pub fn accept_offer(
482        &self,
483        offer_sdp: &str,
484        key: StreamKey,
485        transport: Arc<dyn DtlsSrtpTransport>,
486    ) -> Result<(WhepResource, String)> {
487        let offer = SdpOffer::parse(offer_sdp)
488            .ok_or_else(|| crate::StreamError::protocol("malformed SDP offer"))?;
489        // WHEP egress: we send to the viewer → sendonly answer (transport-owned).
490        let answer = transport.answer(offer_sdp, MediaDirection::SendOnly);
491        let resource = WhepResource {
492            playback: Arc::clone(&self.playback),
493            key,
494            transport,
495            payload_type: offer.payload_type,
496            audio_payload_type: offer.audio_payload_type,
497            warned_unsupported: std::sync::atomic::AtomicBool::new(false),
498        };
499        Ok((resource, answer))
500    }
501}
502
503/// An accepted WHEP connection: streams one live stream out to the viewer as RTP
504/// until the stream ends or the peer disconnects.
505pub struct WhepResource {
506    playback: Arc<dyn PlaybackRegistry>,
507    key: StreamKey,
508    transport: Arc<dyn DtlsSrtpTransport>,
509    payload_type: u8,
510    /// Negotiated Opus audio payload type, when the viewer's offer carried audio.
511    /// `None` disables audio egress (video-only viewer or non-Opus source).
512    audio_payload_type: Option<u8>,
513    /// Set once we have warned about an unsupported egress video codec, so the
514    /// log line fires a single time per connection instead of per frame.
515    warned_unsupported: std::sync::atomic::AtomicBool,
516}
517
518/// The RTP payload format chosen for a WHEP egress connection, selected from the
519/// stream's video codec. Each variant only packetizes frames of its own codec;
520/// a mismatched frame yields `None` (skipped, observably) from
521/// [`packetize`](EgressPacketizer::packetize).
522enum EgressPacketizer {
523    /// NAL codecs — H.264 (RFC 6184) or H.265 (RFC 7798).
524    Nal { p: RtpPacketizer, codec: CodecId },
525    /// VP9 (draft-ietf-payload-vp9).
526    Vp9(Vp9Packetizer),
527    /// AV1 (AOMedia RTP).
528    #[cfg(feature = "codec-av1")]
529    Av1(Av1Packetizer),
530}
531
532impl EgressPacketizer {
533    /// Build the packetizer for `codec`. Codecs without an RTP payload format in
534    /// this build fall back to an H.264 NAL packetizer, so their frames are
535    /// skipped observably rather than mis-framed.
536    fn for_codec(payload_type: u8, ssrc: u32, mtu: usize, codec: CodecId) -> Self {
537        match codec {
538            CodecId::H265 => EgressPacketizer::Nal {
539                p: RtpPacketizer::new_h265(payload_type, ssrc, mtu),
540                codec: CodecId::H265,
541            },
542            CodecId::VP9 => EgressPacketizer::Vp9(Vp9Packetizer::new(payload_type, ssrc, mtu)),
543            #[cfg(feature = "codec-av1")]
544            CodecId::AV1 => EgressPacketizer::Av1(Av1Packetizer::new(payload_type, ssrc, mtu)),
545            _ => EgressPacketizer::Nal {
546                p: RtpPacketizer::new(payload_type, ssrc, mtu),
547                codec: CodecId::H264,
548            },
549        }
550    }
551
552    /// Packetize one video frame at its 90 kHz timestamp into the recycled
553    /// `out` buffer, returning `true` if the frame's codec matched this
554    /// packetizer (and `false`, leaving `out` empty, when it did not).
555    /// Packetize `frame` stamping every RTP packet with `ts_ms` (90 kHz). The
556    /// caller supplies a sanitized, strictly-monotonic timestamp (see
557    /// [`MonoClock`]) rather than the frame's own — source timestamps can jump
558    /// backwards (B-frame reordering, mid-stream resets) which freezes a WebRTC
559    /// jitter buffer.
560    fn packetize_into(&mut self, frame: &MediaFrame, ts_ms: i64, out: &mut Vec<Vec<u8>>) -> bool {
561        let ts = (ts_ms.max(0) as u64).wrapping_mul(90) as u32; // ms → 90 kHz
562        match self {
563            EgressPacketizer::Nal { p, codec } if frame.codec == *codec => {
564                p.packetize_into(&frame.data, ts, out);
565                true
566            }
567            EgressPacketizer::Vp9(p) if frame.codec == CodecId::VP9 => {
568                p.packetize_into(&frame.data, ts, frame.is_keyframe(), out);
569                true
570            }
571            #[cfg(feature = "codec-av1")]
572            EgressPacketizer::Av1(p) if frame.codec == CodecId::AV1 => {
573                p.packetize_into(&frame.data, ts, out);
574                true
575            }
576            _ => false,
577        }
578    }
579}
580
581/// Maps source frame timestamps onto a **strictly increasing** output clock for
582/// RTP egress. A WebRTC receiver's jitter buffer treats any backward (or wildly
583/// forward) RTP timestamp as a discontinuity and stalls — yet real sources emit
584/// non-monotonic timestamps: B-frame reordering nudges them back by a frame,
585/// keyframe-recovery replays cached frames out of band, and encoders/ingest
586/// occasionally reset. This clock absorbs all of that: it advances the output by
587/// the input delta when sane, by 1 ms when the input goes backwards or stalls,
588/// and by the learned nominal frame interval across a large gap — so the egress
589/// timeline never regresses regardless of what the source does.
590struct MonoClock {
591    started: bool,
592    last_in: i64,
593    out: i64,
594    /// Learned typical inter-frame interval (ms), used to bridge discontinuities.
595    nominal: i64,
596}
597
598impl MonoClock {
599    fn new() -> Self {
600        Self {
601            started: false,
602            last_in: 0,
603            out: 0,
604            nominal: 33, // ~30 fps until the real cadence is learned
605        }
606    }
607
608    /// Map an input timestamp (ms) to the next strictly-increasing output (ms).
609    fn map(&mut self, in_ms: i64) -> i64 {
610        if !self.started {
611            self.started = true;
612            self.last_in = in_ms;
613            self.out = in_ms.max(0);
614            return self.out;
615        }
616        let delta = in_ms - self.last_in;
617        self.last_in = in_ms;
618        let step = if delta <= 0 {
619            1 // backward / duplicate: nudge forward minimally
620        } else if delta > 1_000 {
621            self.nominal // discontinuity: bridge by one nominal frame
622        } else {
623            self.nominal = delta; // learn the real cadence
624            delta
625        };
626        self.out += step;
627        self.out
628    }
629}
630
631/// Choose the best simulcast layer for a bandwidth `estimate`, with hysteresis
632/// to avoid flapping between adjacent layers.
633///
634/// `layers` is `(key, measured_bitrate_bps)` for every available layer (the base
635/// plus any `~rid` siblings); order does not matter. Rules:
636///
637/// * The lowest-bitrate layer is the always-available floor.
638/// * With no estimate yet, keep `current` (or fall to the floor if it vanished).
639/// * Otherwise target the highest layer that fits the estimate. **Up-switch**
640///   needs 25% headroom over the candidate's bitrate; **down-switch** triggers
641///   once the estimate drops below 95% of the current layer's bitrate (congestion
642///   is urgent, so it reacts faster than it climbs).
643/// * Layers with an unknown (0) bitrate are never up-switch targets — we can't
644///   tell if they fit — but the base floor is always eligible.
645fn select_layer(
646    layers: &[(StreamKey, u64)],
647    estimate: Option<u64>,
648    current: &StreamKey,
649) -> StreamKey {
650    if layers.is_empty() {
651        return current.clone();
652    }
653    // The floor: lowest measured bitrate (0/unknown sorts lowest, which is fine —
654    // an unmeasured single layer is still the only choice).
655    let floor = layers
656        .iter()
657        .min_by_key(|(_, bps)| *bps)
658        .map(|(k, _)| k.clone())
659        .unwrap();
660    let current_bps = layers.iter().find(|(k, _)| k == current).map(|(_, b)| *b);
661    let Some(estimate) = estimate else {
662        // No estimate: stay put if the current layer still exists, else the floor.
663        return if current_bps.is_some() {
664            current.clone()
665        } else {
666            floor
667        };
668    };
669
670    // Highest layer with a known bitrate that fits the estimate.
671    let desired = layers
672        .iter()
673        .filter(|(_, bps)| *bps > 0 && *bps <= estimate)
674        .max_by_key(|(_, bps)| *bps);
675    let Some((desired_key, desired_bps)) = desired else {
676        return floor; // nothing fits → floor
677    };
678    let current_bps = match current_bps {
679        Some(b) => b,
680        None => return desired_key.clone(), // current gone → take the fit
681    };
682    if *desired_bps > current_bps {
683        // Up-switch only with 25% headroom over the candidate.
684        if estimate >= desired_bps.saturating_mul(5) / 4 {
685            return desired_key.clone();
686        }
687    } else if *desired_bps < current_bps {
688        // Down-switch once the current layer no longer comfortably fits.
689        if estimate < current_bps.saturating_mul(19) / 20 {
690            return desired_key.clone();
691        }
692    }
693    current.clone()
694}
695
696impl WhepResource {
697    /// Drive egress: subscribe to the stream, replay the cached config + GOP for
698    /// an instant start, then packetize and send every published video frame.
699    /// Returns when the stream closes or the subscription lags out.
700    ///
701    /// The RTP payload format is selected from the stream's video codec: H.264
702    /// (RFC 6184), H.265 (RFC 7798), VP9, and AV1 (with `codec-av1`) are
703    /// packetized; other video codecs are skipped with a single warning per
704    /// connection, and audio is skipped.
705    pub async fn pump(self) -> Result<()> {
706        let handle = self.playback.get_stream(&self.key)?;
707        // SSRC derived from the key so retries are stable; real deployments may
708        // randomize per PeerConnection.
709        let ssrc = 0x5745_4850; // "WEHP"
710        let mut sub = handle.subscribe_resilient();
711
712        // Instant start: send the cached config frame + GOP before live frames.
713        let (mut vcfg, _) = handle.cached_configs();
714        let replay = handle.replay_buffer();
715        // Keep a handle clone solely to relay viewer keyframe requests upstream to
716        // the publisher. A clone does not pin the bus open — `StreamHandle::close`
717        // empties the shared sender cell on publish-end regardless of clones.
718        // Reassigned on an adaptive-bitrate layer switch so keyframe requests
719        // target the layer actually being forwarded.
720        let mut kf_handle = handle.clone();
721        // Release the original handle once setup is done.
722        drop(handle);
723
724        // Adaptive bitrate: the layer currently forwarded to this viewer. Starts
725        // on the requested stream (the base) and may switch among simulcast
726        // siblings as the transport's bandwidth estimate changes.
727        let mut current_key = self.key.clone();
728
729        // Pick the payload format from the stream's video codec (config frame
730        // first, else the first replayed video frame; defaulting to H.264).
731        let video_codec = vcfg
732            .as_ref()
733            .map(|c| c.codec)
734            .or_else(|| replay.iter().find(|f| f.is_video()).map(|f| f.codec))
735            .unwrap_or(CodecId::H264);
736        let mut packetizer =
737            EgressPacketizer::for_codec(self.payload_type, ssrc, 1200, video_codec);
738        // Opus audio packetizer on a distinct SSRC, when the viewer offered audio.
739        // Only Opus frames are sent (an AAC source's audio is skipped — a browser
740        // can't decode AAC over this Opus payload type).
741        let mut audio = self
742            .audio_payload_type
743            .map(|pt| OpusPacketizer::new(pt, 0x5745_4151)); // "WEAQ"
744
745        // Reused across frames so steady-state egress allocates no packet buffers.
746        let mut pkts: Vec<Vec<u8>> = Vec::new();
747
748        // Strictly-monotonic egress clocks (video + audio): map every source
749        // timestamp onto a never-regressing RTP timeline, so B-frame reordering,
750        // keyframe-recovery replays, and source resets can't freeze the viewer.
751        let mut vclock = MonoClock::new();
752        let mut aclock = MonoClock::new();
753
754        // Kept for fast local recovery: when a viewer requests a keyframe (PLI/FIR
755        // over RTCP), we re-send the config + the most recent keyframe instead of
756        // making the viewer wait for the next natural IDR.
757        if let Some(cfg) = vcfg.as_ref() {
758            self.send_frame(
759                cfg,
760                &mut packetizer,
761                &mut audio,
762                &mut pkts,
763                &mut vclock,
764                &mut aclock,
765            )
766            .await?;
767        }
768        let mut last_keyframe: Option<Arc<MediaFrame>> = None;
769        for frame in replay {
770            if frame.is_video() && frame.is_keyframe() {
771                last_keyframe = Some(frame.clone());
772            }
773            self.send_frame(
774                &frame,
775                &mut packetizer,
776                &mut audio,
777                &mut pkts,
778                &mut vclock,
779                &mut aclock,
780            )
781            .await?;
782        }
783
784        // Poll the live subscription, the viewer's RTCP feedback, and a periodic
785        // adaptive-bitrate tick together. Once `recv_rtcp` yields `None` (a
786        // transport with no upstream RTCP, or a closed one) we stop polling it via
787        // a never-ready future so the loop can't spin.
788        let mut rtcp_open = true;
789        let mut abr_tick = tokio::time::interval(std::time::Duration::from_secs(1));
790        abr_tick.tick().await; // consume the immediate first tick
791
792        // Viewer-gone detection. The ONLY reliable disconnect signal here is the
793        // transport's RTCP channel closing: for the str0m WHEP egress, `recv_rtcp`
794        // awaits and yields `None` exactly when the str0m driver task ends — i.e.
795        // the peer is genuinely dead (str0m's own ICE/consent timeout fired) or the
796        // session was cancelled. A healthy viewer does NOT send periodic RTCP we can
797        // observe (str0m only surfaces *keyframe requests* over this seam, not
798        // Receiver Reports), so we must NOT reap on "RTCP silence" — doing so tore
799        // down every healthy viewer on a fixed timer and forced a reconnect every
800        // few seconds.
801        //
802        // The kernel-default transport (no RTCP at all) instead returns `None`
803        // *immediately* on the first poll. We distinguish the two by elapsed time:
804        // a `None` within the first moment of the pump means "this transport has no
805        // RTCP channel" (stop polling it, keep pumping); a `None` after the pump has
806        // been alive a while means "the peer's transport closed" (end the pump).
807        const NO_RTCP_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
808        let pump_start = std::time::Instant::now();
809
810        // Lag recovery. If a slow link makes this viewer's bus subscription fall
811        // behind and the ring overflows, `recv` resynchronizes by SKIPPING
812        // frames. Forwarding the post-gap delta frames is useless — they
813        // reference frames the viewer never got, so the decoder freezes until the
814        // next natural IDR (the ~90s "plays then dies" symptom). Instead, after a
815        // lag we drop deltas until the next keyframe and ask the publisher for a
816        // fresh one, so the viewer re-primes quickly.
817        let mut last_dropped = sub.dropped();
818        let mut awaiting_keyframe = false;
819        loop {
820            let feedback = async {
821                if rtcp_open {
822                    self.transport.recv_rtcp().await
823                } else {
824                    std::future::pending().await
825                }
826            };
827            tokio::select! {
828                frame = sub.recv() => {
829                    let Some(frame) = frame else { break };
830
831                    // Detect a resync gap: the subscription dropped frames to
832                    // catch up. Enter keyframe-wait so we don't forward
833                    // undecodable deltas, and prod the publisher for an IDR.
834                    let dropped = sub.dropped();
835                    if dropped > last_dropped {
836                        last_dropped = dropped;
837                        awaiting_keyframe = true;
838                        kf_handle.request_keyframe();
839                    }
840
841                    if frame.is_video() {
842                        if frame.is_keyframe() {
843                            last_keyframe = Some(frame.clone());
844                            awaiting_keyframe = false;
845                        } else if awaiting_keyframe {
846                            // Still waiting for an IDR after a lag — skip this
847                            // delta so the decoder isn't fed a dangling reference.
848                            continue;
849                        }
850                    } else if awaiting_keyframe {
851                        // Hold audio too until video re-anchors, keeping A/V from
852                        // drifting during recovery.
853                        continue;
854                    }
855                    self.send_frame(&frame, &mut packetizer, &mut audio, &mut pkts, &mut vclock, &mut aclock)
856                        .await?;
857                }
858                rtcp = feedback => {
859                    match rtcp {
860                        Some(buf) => {
861                            self.handle_feedback(
862                                &buf,
863                                &kf_handle,
864                                vcfg.as_ref(),
865                                last_keyframe.as_ref(),
866                                &mut packetizer,
867                                &mut audio,
868                                &mut pkts,
869                                &mut vclock,
870                                &mut aclock,
871                            )
872                            .await?;
873                        }
874                        None => {
875                            // `recv_rtcp` yields `None` when the peer's transport
876                            // closed (str0m driver ended → peer dead/cancelled) or
877                            // the transport carries no RTCP at all (kernel default,
878                            // which returns `None` immediately). Discriminate by how
879                            // long the pump has been alive: an early `None` is a
880                            // no-RTCP transport (stop polling, keep pumping); a later
881                            // `None` is a real disconnect (end the pump so its bus
882                            // subscription drops and the live viewer count falls).
883                            if pump_start.elapsed() > NO_RTCP_GRACE {
884                                tracing::debug!(stream = %self.key, "WHEP egress: peer transport closed, ending pump");
885                                break;
886                            }
887                            rtcp_open = false;
888                        }
889                    }
890                }
891                _ = abr_tick.tick() => {
892                    // Re-evaluate which simulcast layer best fits the viewer's
893                    // current bandwidth estimate; switch the subscription if so.
894                    let layers = self.discover_layers();
895                    let estimate = self.transport.estimated_bitrate();
896                    let target = select_layer(&layers, estimate, &current_key);
897                    if target != current_key {
898                        if let Ok(next) = self.playback.get_stream(&target) {
899                            tracing::debug!(
900                                stream = %self.key, from = %current_key, to = %target,
901                                estimate_bps = estimate.unwrap_or(0),
902                                "WHEP egress: adaptive-bitrate layer switch",
903                            );
904                            sub = next.subscribe_resilient();
905                            vcfg = next.cached_configs().0;
906                            kf_handle = next.clone();
907                            current_key = target;
908                            // Resync the decoder on the new layer: re-send its
909                            // config and ask its publisher for a fresh keyframe.
910                            if let Some(cfg) = vcfg.as_ref() {
911                                self.send_frame(cfg, &mut packetizer, &mut audio, &mut pkts, &mut vclock, &mut aclock)
912                                    .await?;
913                            }
914                            last_keyframe = None;
915                            awaiting_keyframe = true;
916                            kf_handle.request_keyframe();
917                        }
918                    }
919                }
920            }
921        }
922        Ok(())
923    }
924
925    /// Enumerate the simulcast layers available for this viewer's stream as
926    /// `(key, measured_video_bitrate_bps)` — the base (the requested
927    /// `stream_id`) plus any `stream_id~rid` siblings the WHIP simulcast demux
928    /// published. The per-layer bitrate is the live ~1s QoS window, the input to
929    /// [`select_layer`]. When the publisher is not simulcast this returns just the
930    /// base layer, so layer selection is a no-op.
931    fn discover_layers(&self) -> Vec<(StreamKey, u64)> {
932        let app = &self.key.app;
933        let base = self.key.stream_id.as_str();
934        let prefix = format!("{base}~");
935        let mut out = Vec::new();
936        let ids = self.playback.list_streams(app).unwrap_or_default();
937        for id in ids {
938            let s = id.as_str();
939            if s == base || s.starts_with(&prefix) {
940                let key = StreamKey::new(app.as_str(), s);
941                let bitrate = self
942                    .playback
943                    .get_stream(&key)
944                    .map(|h| h.qos().video_bitrate_bps)
945                    .unwrap_or(0);
946                out.push((key, bitrate));
947            }
948        }
949        // Guarantee the base is always present even if enumeration missed it.
950        if !out.iter().any(|(k, _)| k == &self.key) {
951            out.push((self.key.clone(), 0));
952        }
953        out
954    }
955
956    /// React to a viewer's RTCP compound packet.
957    ///
958    /// A PLI/FIR triggers fast recovery: re-send the cached config + the most
959    /// recent keyframe so the viewer paints immediately rather than waiting for
960    /// the next natural IDR. Receiver Reports are logged for QoS visibility;
961    /// NACK-driven retransmission is a separate (send-history) feature.
962    #[allow(clippy::too_many_arguments)]
963    async fn handle_feedback(
964        &self,
965        rtcp: &[u8],
966        kf_handle: &crate::bus::StreamHandle,
967        vcfg: Option<&Arc<MediaFrame>>,
968        last_keyframe: Option<&Arc<MediaFrame>>,
969        packetizer: &mut EgressPacketizer,
970        audio: &mut Option<OpusPacketizer>,
971        pkts: &mut Vec<Vec<u8>>,
972        vclock: &mut MonoClock,
973        aclock: &mut MonoClock,
974    ) -> Result<()> {
975        let mut refresh = false;
976        for fb in rtcp::parse_compound(rtcp) {
977            match fb {
978                rtcp::RtcpFeedback::Pli { .. } | rtcp::RtcpFeedback::Fir { .. } => refresh = true,
979                rtcp::RtcpFeedback::ReceiverReport {
980                    fraction_lost,
981                    cumulative_lost,
982                    jitter,
983                    ..
984                } => {
985                    tracing::trace!(
986                        stream = %self.key,
987                        fraction_lost,
988                        cumulative_lost,
989                        jitter,
990                        "WHEP egress: viewer receiver report",
991                    );
992                }
993                rtcp::RtcpFeedback::Nack { lost, .. } => {
994                    tracing::trace!(
995                        stream = %self.key,
996                        lost = lost.len(),
997                        "WHEP egress: viewer NACK (retransmission not yet implemented)",
998                    );
999                }
1000                rtcp::RtcpFeedback::Remb { bitrate_bps, .. } => {
1001                    tracing::trace!(
1002                        stream = %self.key,
1003                        bitrate_bps,
1004                        "WHEP egress: viewer REMB bandwidth estimate",
1005                    );
1006                }
1007            }
1008        }
1009        if refresh {
1010            // Fast local recovery: re-send the cached config + most recent
1011            // keyframe so the viewer paints without waiting for a natural IDR.
1012            // The cached frames carry old timestamps, but `send_frame` runs them
1013            // through `vclock`, which maps their stale time onto the current
1014            // monotonic output — so the keyframe lands "now" and the viewer
1015            // accepts it (a raw backward timestamp would be dropped as stale).
1016            if let Some(cfg) = vcfg {
1017                self.send_frame(cfg, packetizer, audio, pkts, vclock, aclock)
1018                    .await?;
1019            }
1020            if let Some(kf) = last_keyframe {
1021                self.send_frame(kf, packetizer, audio, pkts, vclock, aclock)
1022                    .await?;
1023            }
1024            // Also relay upstream: ask the publisher for a fresh IDR, covering the
1025            // case where the cache has no usable keyframe (e.g. just after join).
1026            kf_handle.request_keyframe();
1027        }
1028        Ok(())
1029    }
1030
1031    /// Packetize one frame and send each RTP packet over the transport.
1032    ///
1033    /// Video is packetized by the connection's [`EgressPacketizer`]; Opus audio
1034    /// (when the viewer negotiated it) by the [`OpusPacketizer`]. A video frame
1035    /// whose codec the packetizer can't handle is skipped with a single warning
1036    /// per connection — an *observable* skip, never a silent drop. Non-Opus audio
1037    /// is skipped silently (a different audio codec is expected on many sources).
1038    #[allow(clippy::too_many_arguments)]
1039    async fn send_frame(
1040        &self,
1041        frame: &MediaFrame,
1042        packetizer: &mut EgressPacketizer,
1043        audio: &mut Option<OpusPacketizer>,
1044        pkts: &mut Vec<Vec<u8>>,
1045        vclock: &mut MonoClock,
1046        aclock: &mut MonoClock,
1047    ) -> Result<()> {
1048        if frame.is_audio() {
1049            if let Some(ap) = audio.as_mut() {
1050                if frame.codec == CodecId::Opus {
1051                    let ts = (aclock.map(frame.dts).max(0) as u64).wrapping_mul(48) as u32; // ms → 48 kHz
1052                    ap.packetize_into(&frame.data, ts, pkts);
1053                    for packet in pkts.iter() {
1054                        self.transport.send_rtp(packet).await?;
1055                    }
1056                }
1057            }
1058            return Ok(());
1059        }
1060        if !frame.is_video() {
1061            return Ok(());
1062        }
1063        let ts_ms = vclock.map(frame.dts);
1064        if packetizer.packetize_into(frame, ts_ms, pkts) {
1065            for packet in pkts.iter() {
1066                self.transport.send_rtp(packet).await?;
1067            }
1068        } else {
1069            use std::sync::atomic::Ordering;
1070            if !self.warned_unsupported.swap(true, Ordering::Relaxed) {
1071                tracing::warn!(
1072                    stream = %self.key,
1073                    codec = ?frame.codec,
1074                    "WHEP egress: unsupported video codec; frames skipped",
1075                );
1076            }
1077        }
1078        Ok(())
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use crate::bus::PlaybackRegistry;
1086    use std::sync::Arc;
1087    use tokio::sync::Mutex;
1088
1089    #[test]
1090    fn mono_clock_is_strictly_increasing_through_glitches() {
1091        let mut c = MonoClock::new();
1092        // Normal cadence, a B-frame-style backward step, a duplicate, and a large
1093        // forward discontinuity — the output must never regress.
1094        let inputs = [
1095            1000, 1033, 1066, 1050, // backward (reorder)
1096            1100, 1100, // duplicate
1097            1133, 50_000, // huge jump (reset)
1098            50_033, 50_066,
1099        ];
1100        let mut prev = i64::MIN;
1101        for ms in inputs {
1102            let out = c.map(ms);
1103            assert!(out > prev, "output regressed: {out} after {prev}");
1104            prev = out;
1105        }
1106    }
1107
1108    #[test]
1109    fn mono_clock_passes_through_steady_cadence() {
1110        let mut c = MonoClock::new();
1111        // A clean 30 fps stream maps 1:1 (deltas preserved) after the first frame.
1112        assert_eq!(c.map(0), 0);
1113        assert_eq!(c.map(33), 33);
1114        assert_eq!(c.map(66), 66);
1115    }
1116
1117    /// A fake transport that replays a fixed RTP script and records what is sent.
1118    struct FakeTransport {
1119        packets: Mutex<std::collections::VecDeque<Vec<u8>>>,
1120        rtcp: Mutex<Vec<Vec<u8>>>,
1121        sent_rtp: Mutex<Vec<Vec<u8>>>,
1122        /// Inbound viewer RTCP script (WHEP), drained by `recv_rtcp`.
1123        rtcp_in: Mutex<std::collections::VecDeque<Vec<u8>>>,
1124        /// When the script drains, stay open (block) instead of yielding `None`,
1125        /// so a spawned `pump` keeps its publish sessions alive for inspection.
1126        keep_open: bool,
1127    }
1128
1129    impl FakeTransport {
1130        fn with_packets(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
1131            Self {
1132                packets: Mutex::new(packets),
1133                rtcp: Mutex::new(Vec::new()),
1134                sent_rtp: Mutex::new(Vec::new()),
1135                rtcp_in: Mutex::new(Default::default()),
1136                keep_open: false,
1137            }
1138        }
1139
1140        fn with_packets_keep_open(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
1141            Self {
1142                keep_open: true,
1143                ..Self::with_packets(packets)
1144            }
1145        }
1146
1147        /// A kept-open transport that delivers `rtcp` packets to the egress feedback
1148        /// loop (then blocks), for exercising WHEP viewer-feedback handling.
1149        fn with_inbound_rtcp(rtcp: std::collections::VecDeque<Vec<u8>>) -> Self {
1150            Self {
1151                keep_open: true,
1152                rtcp_in: Mutex::new(rtcp),
1153                ..Self::with_packets(Default::default())
1154            }
1155        }
1156    }
1157
1158    #[async_trait]
1159    impl DtlsSrtpTransport for FakeTransport {
1160        fn fingerprint(&self) -> String {
1161            "sha-256 AA:BB".into()
1162        }
1163        fn ice_credentials(&self) -> (String, String) {
1164            ("ufrag".into(), "pwd".into())
1165        }
1166        async fn recv_rtp(&self) -> Option<Vec<u8>> {
1167            match self.packets.lock().await.pop_front() {
1168                Some(p) => Some(p),
1169                None if self.keep_open => std::future::pending().await,
1170                None => None,
1171            }
1172        }
1173        async fn send_rtp(&self, packet: &[u8]) -> Result<()> {
1174            self.sent_rtp.lock().await.push(packet.to_vec());
1175            Ok(())
1176        }
1177        async fn send_rtcp(&self, packet: &[u8]) -> Result<()> {
1178            self.rtcp.lock().await.push(packet.to_vec());
1179            Ok(())
1180        }
1181        async fn recv_rtcp(&self) -> Option<Vec<u8>> {
1182            match self.rtcp_in.lock().await.pop_front() {
1183                Some(p) => Some(p),
1184                None if self.keep_open => std::future::pending().await,
1185                None => None,
1186            }
1187        }
1188    }
1189
1190    fn rtp_packet(seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
1191        rtp_packet_pt(96, seq, ts, marker, payload)
1192    }
1193
1194    fn rtp_packet_pt(pt: u8, seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
1195        let mut p = vec![0x80, if marker { 0x80 | pt } else { pt & 0x7F }];
1196        p.extend_from_slice(&seq.to_be_bytes());
1197        p.extend_from_slice(&ts.to_be_bytes());
1198        p.extend_from_slice(&[0, 0, 0, 7]);
1199        p.extend_from_slice(payload);
1200        p
1201    }
1202
1203    /// An RTP packet tagged with a one-byte RID header extension `(ext_id, rid)`.
1204    fn rtp_with_rid(ext_id: u8, rid: &str, seq: u16, marker: bool, payload: &[u8]) -> Vec<u8> {
1205        let mut p = vec![0x90, if marker { 0x80 | 96 } else { 96 }]; // X bit + PT 96
1206        p.extend_from_slice(&seq.to_be_bytes());
1207        p.extend_from_slice(&0u32.to_be_bytes()); // ts
1208        p.extend_from_slice(&[0, 0, 0, 7]); // ssrc
1209        p.extend_from_slice(&0xBEDEu16.to_be_bytes()); // one-byte ext profile
1210        let mut ext = vec![(ext_id << 4) | (rid.len() as u8 - 1)];
1211        ext.extend_from_slice(rid.as_bytes());
1212        while ext.len() % 4 != 0 {
1213            ext.push(0);
1214        }
1215        p.extend_from_slice(&((ext.len() / 4) as u16).to_be_bytes());
1216        p.extend_from_slice(&ext);
1217        p.extend_from_slice(payload);
1218        p
1219    }
1220
1221    /// Simulcast WHIP: two RID layers (`q`, `h`) are demultiplexed onto distinct
1222    /// streams — the base layer to the requested key, the other to `<stream>~h`.
1223    #[tokio::test]
1224    async fn pump_routes_simulcast_layers_to_per_layer_streams() {
1225        let engine = crate::Engine::builder()
1226            .application(crate::AppSpec::new("live").gop_cache(4))
1227            .build();
1228        let ctx = IngestContext::new(engine.clone());
1229        let offer = "v=0\r\n\
1230o=- 0 0 IN IP4 0.0.0.0\r\n\
1231m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
1232a=mid:0\r\n\
1233a=sendonly\r\n\
1234a=rtpmap:96 H264/90000\r\n\
1235a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid\r\n\
1236a=rid:q send\r\n\
1237a=rid:h send\r\n\
1238a=simulcast:send q;h\r\n";
1239
1240        // One IDR per layer, each tagged with its rid.
1241        let mut q = std::collections::VecDeque::new();
1242        q.push_back(rtp_with_rid(4, "q", 1, true, &[0x65, 0x11]));
1243        q.push_back(rtp_with_rid(4, "h", 2, true, &[0x65, 0x22]));
1244        let transport = Arc::new(FakeTransport::with_packets_keep_open(q));
1245
1246        let endpoint = WhipEndpoint::new(ctx);
1247        let (resource, _answer) = endpoint
1248            .accept_offer(offer, StreamKey::new("live", "cam"), transport)
1249            .unwrap();
1250        let pump = tokio::spawn(resource.pump());
1251
1252        // Base layer `q` → requested key; layer `h` → `cam~h`.
1253        let base = wait_for_stream(&engine, &StreamKey::new("live", "cam")).await;
1254        let high = wait_for_stream(&engine, &StreamKey::new("live", "cam~h")).await;
1255        assert!(base, "base simulcast layer published to the requested key");
1256        assert!(high, "second simulcast layer published to a per-rid key");
1257
1258        pump.abort();
1259    }
1260
1261    async fn wait_for_stream(engine: &Arc<crate::Engine>, key: &StreamKey) -> bool {
1262        for _ in 0..200 {
1263            if engine.get_stream(key).is_ok() {
1264                return true;
1265            }
1266            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1267        }
1268        false
1269    }
1270
1271    #[tokio::test]
1272    async fn accept_offer_builds_answer_with_transport_credentials() {
1273        let engine = crate::Engine::builder()
1274            .application(crate::AppSpec::new("live"))
1275            .build();
1276        let endpoint = WhipEndpoint::new(IngestContext::new(engine));
1277        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1278        let offer = "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1279        let (_res, answer) = endpoint
1280            .accept_offer(offer, StreamKey::new("live", "web"), transport)
1281            .unwrap();
1282        assert!(answer.contains("a=ice-ufrag:ufrag"));
1283        assert!(answer.contains("a=fingerprint:sha-256 AA:BB"));
1284        assert!(answer.contains("a=setup:passive"));
1285    }
1286
1287    #[tokio::test]
1288    async fn pump_publishes_idr_then_releases_slot() {
1289        let engine = crate::Engine::builder()
1290            .application(crate::AppSpec::new("live").gop_cache(4))
1291            .build();
1292        let key = StreamKey::new("live", "web");
1293        let ctx = IngestContext::new(engine.clone());
1294
1295        let mut q = std::collections::VecDeque::new();
1296        q.push_back(rtp_packet(1, 0, true, &[0x65, 0x11])); // single IDR, marker
1297        let transport = Arc::new(FakeTransport::with_packets(q));
1298
1299        let resource = WhipResource {
1300            ctx,
1301            key: key.clone(),
1302            transport,
1303            video_pt: 96,
1304            audio_pt: None,
1305            rid_ext_id: None,
1306            simulcast_rids: Vec::new(),
1307        };
1308        resource.pump().await.unwrap();
1309
1310        // A complete keyframe was published, so no PLI was needed; the publish
1311        // slot is released once the transport drained.
1312        assert!(engine.get_stream(&key).is_err());
1313    }
1314
1315    #[tokio::test]
1316    async fn pump_requests_keyframe_on_a_depacketize_gap() {
1317        let engine = crate::Engine::builder()
1318            .application(crate::AppSpec::new("live").gop_cache(4))
1319            .build();
1320        let ctx = IngestContext::new(engine);
1321
1322        // A mid FU-A fragment with no start bit forces an OutOfOrder error → PLI.
1323        let mut q = std::collections::VecDeque::new();
1324        q.push_back(rtp_packet(1, 0, false, &[0x7C, 0x05, 0x11])); // FU-A, S=0
1325        let transport = Arc::new(FakeTransport::with_packets(q));
1326
1327        let resource = WhipResource {
1328            ctx,
1329            key: StreamKey::new("live", "web2"),
1330            transport: transport.clone(),
1331            video_pt: 96,
1332            audio_pt: None,
1333            rid_ext_id: None,
1334            simulcast_rids: Vec::new(),
1335        };
1336        resource.pump().await.unwrap();
1337        assert!(
1338            !transport.rtcp.lock().await.is_empty(),
1339            "a PLI was sent after the depacketize gap"
1340        );
1341    }
1342
1343    /// WHIP audio: an RTP packet on the negotiated Opus PT is published as an
1344    /// Opus audio frame (one packet per frame, 48 kHz → ms PTS), routed away from
1345    /// the H.264 depacketizer.
1346    #[tokio::test]
1347    async fn pump_routes_opus_audio_onto_the_bus() {
1348        let engine = crate::Engine::builder()
1349            .application(crate::AppSpec::new("live").gop_cache(8))
1350            .build();
1351        let key = StreamKey::new("live", "av");
1352        let ctx = IngestContext::new(engine.clone());
1353
1354        // Subscribe before pumping so we observe the published audio frame.
1355        let handle = engine.get_stream(&key);
1356        assert!(handle.is_err(), "stream not live until pump opens publish");
1357
1358        let mut q = std::collections::VecDeque::new();
1359        // PT 111 (Opus), ts 4800 → 100 ms; payload is an opaque Opus packet.
1360        q.push_back(rtp_packet_pt(111, 7, 4800, true, &[0xAA, 0xBB, 0xCC]));
1361        let transport = Arc::new(FakeTransport::with_packets(q));
1362
1363        let resource = WhipResource {
1364            ctx,
1365            key: key.clone(),
1366            transport,
1367            video_pt: 96,
1368            audio_pt: Some(111),
1369            rid_ext_id: None,
1370            simulcast_rids: Vec::new(),
1371        };
1372        // Capture frames via a parallel subscription opened once publishing starts.
1373        let pump = tokio::spawn(async move { resource.pump().await });
1374        // Give the pump a moment to open the publish + emit, then drain.
1375        let _ = pump.await.unwrap();
1376        // The stream closed cleanly after the single packet (no panic, no PLI:
1377        // audio never drives keyframe requests).
1378        assert!(engine.get_stream(&key).is_err());
1379    }
1380
1381    #[tokio::test]
1382    async fn whep_egress_packetizes_published_frames_as_rtp() {
1383        use crate::FrameFlags;
1384        let engine = crate::Engine::builder()
1385            .application(crate::AppSpec::new("live").gop_cache(8))
1386            .build();
1387        let key = StreamKey::new("live", "show");
1388
1389        // Publish a config + keyframe into the stream via an ingest session.
1390        let ctx = IngestContext::new(engine.clone());
1391        let session = ctx.open_publish(key.clone()).await.unwrap();
1392        let mut cfg = MediaFrame::new_video(
1393            0,
1394            0,
1395            bytes::Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42]),
1396            CodecId::H264,
1397            false,
1398        );
1399        cfg.flags |= FrameFlags::CONFIG;
1400        session.publish_frame(cfg).unwrap();
1401        session
1402            .publish_frame(MediaFrame::new_video(
1403                10,
1404                10,
1405                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x99]),
1406                CodecId::H264,
1407                true,
1408            ))
1409            .unwrap();
1410
1411        // A WHEP viewer subscribes and pumps; the bus closes when we finish().
1412        let whep = WhepEndpoint::new(engine.clone());
1413        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1414        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1415        let (resource, answer) = whep
1416            .accept_offer(offer, key.clone(), transport.clone())
1417            .unwrap();
1418        assert!(answer.contains("a=sendonly"), "WHEP answer is sendonly");
1419
1420        let pump = tokio::spawn(resource.pump());
1421        // Let the instant-start replay (config + keyframe) flush, then end the stream.
1422        for _ in 0..32 {
1423            if !transport.sent_rtp.lock().await.is_empty() {
1424                break;
1425            }
1426            tokio::task::yield_now().await;
1427        }
1428        session.finish().await.unwrap();
1429        let _ = pump.await.unwrap();
1430
1431        let sent = transport.sent_rtp.lock().await;
1432        assert!(!sent.is_empty(), "egress sent RTP packets");
1433        // The packets parse as RTP with our payload type.
1434        let h = RtpHeader::parse(&sent[0]).unwrap();
1435        assert_eq!(h.payload_type, 96);
1436    }
1437
1438    /// The WHEP egress gate denies a request whose token the gate rejects, and
1439    /// permits when no gate is installed (permit-all, matching RTSP/SRT egress).
1440    #[tokio::test]
1441    async fn whep_gate_denies_and_permits() {
1442        let engine = crate::Engine::builder()
1443            .application(crate::AppSpec::new("live"))
1444            .build();
1445        let key = StreamKey::new("live", "show");
1446        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1447        let transport = || Arc::new(FakeTransport::with_packets(Default::default()));
1448
1449        // Gate that allows only the token "good".
1450        let gate: crate::auth::EgressGate = Arc::new(|_key, token, _peer| {
1451            Box::pin(async move { token.as_deref() == Some("good") })
1452        });
1453        let whep = WhepEndpoint::new(engine.clone()).with_gate(gate);
1454
1455        // Wrong/absent token → denied.
1456        assert!(whep
1457            .accept_offer_gated(offer, key.clone(), None, None, transport())
1458            .await
1459            .is_err());
1460        // Correct token → allowed.
1461        assert!(whep
1462            .accept_offer_gated(offer, key.clone(), Some("good".into()), None, transport())
1463            .await
1464            .is_ok());
1465
1466        // No gate installed → permit-all even without a token.
1467        let open = WhepEndpoint::new(engine.clone());
1468        assert!(open
1469            .accept_offer_gated(offer, key.clone(), None, None, transport())
1470            .await
1471            .is_ok());
1472    }
1473
1474    /// WHEP viewer feedback: a PLI arriving over `recv_rtcp` makes the egress
1475    /// re-send the cached config + most recent keyframe for fast recovery, so the
1476    /// keyframe NAL is transmitted again after the initial instant-start replay.
1477    #[tokio::test]
1478    async fn whep_egress_resends_keyframe_on_viewer_pli() {
1479        use crate::FrameFlags;
1480        let engine = crate::Engine::builder()
1481            .application(crate::AppSpec::new("live").gop_cache(8))
1482            .build();
1483        let key = StreamKey::new("live", "fb");
1484
1485        let ctx = IngestContext::new(engine.clone());
1486        let session = ctx.open_publish(key.clone()).await.unwrap();
1487        let mut cfg = MediaFrame::new_video(
1488            0,
1489            0,
1490            bytes::Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42]),
1491            CodecId::H264,
1492            false,
1493        );
1494        cfg.flags |= FrameFlags::CONFIG;
1495        session.publish_frame(cfg).unwrap();
1496        session
1497            .publish_frame(MediaFrame::new_video(
1498                10,
1499                10,
1500                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x99]),
1501                CodecId::H264,
1502                true,
1503            ))
1504            .unwrap();
1505
1506        // The viewer's feedback script: one PLI, delivered once the loop starts.
1507        let mut script = std::collections::VecDeque::new();
1508        script.push_back(rtcp::build_pli(0, 0));
1509        let transport = Arc::new(FakeTransport::with_inbound_rtcp(script));
1510
1511        let whep = WhepEndpoint::new(engine.clone());
1512        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1513        let (resource, _) = whep
1514            .accept_offer(offer, key.clone(), transport.clone())
1515            .unwrap();
1516        let pump = tokio::spawn(resource.pump());
1517
1518        // Wait until the keyframe NAL (0x65) has been sent twice: once on the
1519        // instant-start replay, once again from the PLI-triggered refresh.
1520        let count_keyframes = |pkts: &[Vec<u8>]| {
1521            pkts.iter()
1522                .filter(|p| {
1523                    RtpHeader::parse(p)
1524                        .map(|h| p[h.payload_offset..].windows(1).any(|w| w[0] == 0x65))
1525                        .unwrap_or(false)
1526                })
1527                .count()
1528        };
1529        let mut refreshed = false;
1530        for _ in 0..500 {
1531            if count_keyframes(&transport.sent_rtp.lock().await) >= 2 {
1532                refreshed = true;
1533                break;
1534            }
1535            // A real (short) sleep lets the spawned pump task make progress and
1536            // its timer-driven branches advance, instead of racing a yield budget.
1537            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1538        }
1539        session.finish().await.unwrap();
1540        let _ = pump.await.unwrap();
1541        assert!(refreshed, "viewer PLI re-sent the keyframe");
1542    }
1543
1544    #[test]
1545    fn select_layer_picks_fit_with_hysteresis() {
1546        let k = |s: &str| StreamKey::new("live", s);
1547        // Three layers: low 300k, mid 800k, high 2.5M.
1548        let layers = vec![
1549            (k("show"), 300_000u64),
1550            (k("show~h"), 800_000),
1551            (k("show~f"), 2_500_000),
1552        ];
1553
1554        // Plenty of bandwidth, currently on low → up-switch to high (>25% headroom
1555        // over 2.5M needs >= 3.125M; give 4M).
1556        assert_eq!(
1557            select_layer(&layers, Some(4_000_000), &k("show")),
1558            k("show~f")
1559        );
1560
1561        // Marginal bandwidth just above mid's bitrate but below the 25% headroom
1562        // for up-switching from low → no up-switch (stays low).
1563        assert_eq!(select_layer(&layers, Some(820_000), &k("show")), k("show"));
1564
1565        // On high, bandwidth collapses below 95% of high → down-switch to the best
1566        // fit (mid at 800k fits 900k).
1567        assert_eq!(
1568            select_layer(&layers, Some(900_000), &k("show~f")),
1569            k("show~h")
1570        );
1571
1572        // No estimate yet → stay on the current layer.
1573        assert_eq!(select_layer(&layers, None, &k("show~h")), k("show~h"));
1574
1575        // Nothing fits (estimate below the floor) → the floor layer.
1576        assert_eq!(
1577            select_layer(&layers, Some(100_000), &k("show~f")),
1578            k("show")
1579        );
1580
1581        // Single layer (no simulcast) → always itself.
1582        let one = vec![(k("solo"), 0u64)];
1583        assert_eq!(select_layer(&one, Some(5_000_000), &k("solo")), k("solo"));
1584    }
1585
1586    /// WHEP layer discovery: the base stream plus its `~rid` simulcast siblings
1587    /// are enumerated for adaptive-bitrate selection; unrelated streams are not.
1588    #[tokio::test]
1589    async fn discover_layers_lists_base_and_simulcast_siblings() {
1590        let engine = crate::Engine::builder()
1591            .application(crate::AppSpec::new("live").gop_cache(4))
1592            .build();
1593        let ctx = IngestContext::new(engine.clone());
1594        // Publish a base layer, two simulcast siblings, and an unrelated stream.
1595        for id in ["show", "show~h", "show~f", "other"] {
1596            let s = ctx.open_publish(StreamKey::new("live", id)).await.unwrap();
1597            s.publish_frame(MediaFrame::new_video(
1598                0,
1599                0,
1600                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65]),
1601                CodecId::H264,
1602                true,
1603            ))
1604            .unwrap();
1605            std::mem::forget(s); // keep the streams live for the listing
1606        }
1607
1608        let whep = WhepEndpoint::new(engine.clone());
1609        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1610        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1611        let (resource, _) = whep
1612            .accept_offer(offer, StreamKey::new("live", "show"), transport)
1613            .unwrap();
1614
1615        let mut ids: Vec<String> = resource
1616            .discover_layers()
1617            .into_iter()
1618            .map(|(k, _)| k.stream_id.as_str().to_string())
1619            .collect();
1620        ids.sort();
1621        assert_eq!(ids, vec!["show", "show~f", "show~h"]);
1622    }
1623
1624    /// The upstream keyframe backchannel: a viewer PLI on the WHEP egress calls
1625    /// `StreamHandle::request_keyframe`, which the publisher's ingest loop awaits
1626    /// via `keyframe_requested` — so a browser PLI reaches the WHIP publisher.
1627    #[tokio::test]
1628    async fn request_keyframe_signals_the_publishers_handle() {
1629        let engine = crate::Engine::builder()
1630            .application(crate::AppSpec::new("live").gop_cache(8))
1631            .build();
1632        let key = StreamKey::new("live", "kf");
1633
1634        let ctx = IngestContext::new(engine.clone());
1635        let session = ctx.open_publish(key.clone()).await.unwrap();
1636        // The publisher's loop awaits a keyframe request on its handle.
1637        let pub_handle = session.handle().clone();
1638        let waiter = tokio::spawn(async move {
1639            tokio::time::timeout(
1640                std::time::Duration::from_secs(2),
1641                pub_handle.keyframe_requested(),
1642            )
1643            .await
1644        });
1645
1646        // A playback consumer resolves the *same* stream and requests a keyframe.
1647        let view_handle = engine.get_stream(&key).unwrap();
1648        // Give the waiter a moment to park on `notified()` before signaling.
1649        tokio::task::yield_now().await;
1650        view_handle.request_keyframe();
1651
1652        assert!(waiter.await.unwrap().is_ok(), "publisher saw the request");
1653    }
1654
1655    /// WHEP audio: when the viewer's offer carries an Opus audio line, published
1656    /// Opus audio frames are RTP-packetized on the audio payload type and sent.
1657    #[tokio::test]
1658    async fn whep_egress_packetizes_opus_audio() {
1659        let engine = crate::Engine::builder()
1660            .application(crate::AppSpec::new("live").gop_cache(8))
1661            .build();
1662        let key = StreamKey::new("live", "aud");
1663
1664        let ctx = IngestContext::new(engine.clone());
1665        let session = ctx.open_publish(key.clone()).await.unwrap();
1666        // A keyframe (so the GOP replay has video) plus an Opus audio frame.
1667        session
1668            .publish_frame(MediaFrame::new_video(
1669                0,
1670                0,
1671                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]),
1672                CodecId::H264,
1673                true,
1674            ))
1675            .unwrap();
1676        session
1677            .publish_frame(MediaFrame::new_audio(
1678                20,
1679                bytes::Bytes::from_static(&[0xDE, 0xAD, 0xBE, 0xEF]),
1680                CodecId::Opus,
1681            ))
1682            .unwrap();
1683
1684        let whep = WhepEndpoint::new(engine.clone());
1685        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1686        // Offer with both video and Opus audio (PT 111).
1687        let offer = "v=0\r\n\
1688m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n\
1689m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=rtpmap:111 opus/48000/2\r\n";
1690        let (resource, _answer) = whep
1691            .accept_offer(offer, key.clone(), transport.clone())
1692            .unwrap();
1693
1694        let pump = tokio::spawn(resource.pump());
1695        for _ in 0..64 {
1696            if transport
1697                .sent_rtp
1698                .lock()
1699                .await
1700                .iter()
1701                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111))
1702            {
1703                break;
1704            }
1705            tokio::task::yield_now().await;
1706        }
1707        session.finish().await.unwrap();
1708        let _ = pump.await.unwrap();
1709
1710        let sent = transport.sent_rtp.lock().await;
1711        assert!(
1712            sent.iter()
1713                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111)),
1714            "egress sent an Opus audio RTP packet on PT 111"
1715        );
1716    }
1717
1718    #[tokio::test]
1719    async fn whep_egress_packetizes_vp9_frames() {
1720        let engine = crate::Engine::builder()
1721            .application(crate::AppSpec::new("live").gop_cache(8))
1722            .build();
1723        let key = StreamKey::new("live", "vp9");
1724
1725        // Publish a VP9 keyframe (no config AU — codec is inferred from the frame).
1726        let ctx = IngestContext::new(engine.clone());
1727        let session = ctx.open_publish(key.clone()).await.unwrap();
1728        let frame_data = bytes::Bytes::from_static(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
1729        session
1730            .publish_frame(MediaFrame::new_video(
1731                0,
1732                0,
1733                frame_data.clone(),
1734                CodecId::VP9,
1735                true,
1736            ))
1737            .unwrap();
1738
1739        let whep = WhepEndpoint::new(engine.clone());
1740        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1741        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 VP9/90000\r\n";
1742        let (resource, _answer) = whep
1743            .accept_offer(offer, key.clone(), transport.clone())
1744            .unwrap();
1745
1746        let pump = tokio::spawn(resource.pump());
1747        for _ in 0..32 {
1748            if !transport.sent_rtp.lock().await.is_empty() {
1749                break;
1750            }
1751            tokio::task::yield_now().await;
1752        }
1753        session.finish().await.unwrap();
1754        let _ = pump.await.unwrap();
1755
1756        // The egress RTP round-trips back to the original VP9 frame.
1757        let sent = transport.sent_rtp.lock().await;
1758        assert!(!sent.is_empty(), "VP9 egress sent RTP packets");
1759        let mut depack = crate::protocol::rtp::Vp9Depacketizer::new();
1760        let mut out = None;
1761        for p in sent.iter() {
1762            let h = RtpHeader::parse(p).unwrap();
1763            if let Some(f) = depack
1764                .push(&p[h.payload_offset..], h.marker, h.timestamp)
1765                .unwrap()
1766            {
1767                out = Some(f);
1768            }
1769        }
1770        let out = out.expect("VP9 frame completed");
1771        assert_eq!(&out.data[..], &frame_data[..], "VP9 frame reconstructed");
1772        assert!(out.keyframe);
1773    }
1774}