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 watchdog: a connected WHEP player sends RTCP receiver
793        // reports continuously. Once it has reported at least once and then goes
794        // silent for `RTCP_SILENCE_TIMEOUT`, the peer has disconnected (ICE
795        // doesn't always surface as a send error) — end the pump so the viewer's
796        // bus subscription is released and the live viewer count drops.
797        const RTCP_SILENCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
798        let mut last_rtcp = std::time::Instant::now();
799        let mut rtcp_seen = false;
800
801        // Lag recovery. If a slow link makes this viewer's bus subscription fall
802        // behind and the ring overflows, `recv` resynchronizes by SKIPPING
803        // frames. Forwarding the post-gap delta frames is useless — they
804        // reference frames the viewer never got, so the decoder freezes until the
805        // next natural IDR (the ~90s "plays then dies" symptom). Instead, after a
806        // lag we drop deltas until the next keyframe and ask the publisher for a
807        // fresh one, so the viewer re-primes quickly.
808        let mut last_dropped = sub.dropped();
809        let mut awaiting_keyframe = false;
810        loop {
811            let feedback = async {
812                if rtcp_open {
813                    self.transport.recv_rtcp().await
814                } else {
815                    std::future::pending().await
816                }
817            };
818            tokio::select! {
819                frame = sub.recv() => {
820                    let Some(frame) = frame else { break };
821
822                    // Detect a resync gap: the subscription dropped frames to
823                    // catch up. Enter keyframe-wait so we don't forward
824                    // undecodable deltas, and prod the publisher for an IDR.
825                    let dropped = sub.dropped();
826                    if dropped > last_dropped {
827                        last_dropped = dropped;
828                        awaiting_keyframe = true;
829                        kf_handle.request_keyframe();
830                    }
831
832                    if frame.is_video() {
833                        if frame.is_keyframe() {
834                            last_keyframe = Some(frame.clone());
835                            awaiting_keyframe = false;
836                        } else if awaiting_keyframe {
837                            // Still waiting for an IDR after a lag — skip this
838                            // delta so the decoder isn't fed a dangling reference.
839                            continue;
840                        }
841                    } else if awaiting_keyframe {
842                        // Hold audio too until video re-anchors, keeping A/V from
843                        // drifting during recovery.
844                        continue;
845                    }
846                    self.send_frame(&frame, &mut packetizer, &mut audio, &mut pkts, &mut vclock, &mut aclock)
847                        .await?;
848                }
849                rtcp = feedback => {
850                    match rtcp {
851                        Some(buf) => {
852                            last_rtcp = std::time::Instant::now();
853                            rtcp_seen = true;
854                            self.handle_feedback(
855                                &buf,
856                                &kf_handle,
857                                vcfg.as_ref(),
858                                last_keyframe.as_ref(),
859                                &mut packetizer,
860                                &mut audio,
861                                &mut pkts,
862                                &mut vclock,
863                                &mut aclock,
864                            )
865                            .await?;
866                        }
867                        None => rtcp_open = false,
868                    }
869                }
870                _ = abr_tick.tick() => {
871                    // Viewer-gone watchdog (see above): break once a previously
872                    // reporting peer has been RTCP-silent past the timeout.
873                    if rtcp_seen && last_rtcp.elapsed() > RTCP_SILENCE_TIMEOUT {
874                        tracing::debug!(stream = %self.key, "WHEP egress: viewer RTCP-silent, ending pump");
875                        break;
876                    }
877                    // Re-evaluate which simulcast layer best fits the viewer's
878                    // current bandwidth estimate; switch the subscription if so.
879                    let layers = self.discover_layers();
880                    let estimate = self.transport.estimated_bitrate();
881                    let target = select_layer(&layers, estimate, &current_key);
882                    if target != current_key {
883                        if let Ok(next) = self.playback.get_stream(&target) {
884                            tracing::debug!(
885                                stream = %self.key, from = %current_key, to = %target,
886                                estimate_bps = estimate.unwrap_or(0),
887                                "WHEP egress: adaptive-bitrate layer switch",
888                            );
889                            sub = next.subscribe_resilient();
890                            vcfg = next.cached_configs().0;
891                            kf_handle = next.clone();
892                            current_key = target;
893                            // Resync the decoder on the new layer: re-send its
894                            // config and ask its publisher for a fresh keyframe.
895                            if let Some(cfg) = vcfg.as_ref() {
896                                self.send_frame(cfg, &mut packetizer, &mut audio, &mut pkts, &mut vclock, &mut aclock)
897                                    .await?;
898                            }
899                            last_keyframe = None;
900                            awaiting_keyframe = true;
901                            kf_handle.request_keyframe();
902                        }
903                    }
904                }
905            }
906        }
907        Ok(())
908    }
909
910    /// Enumerate the simulcast layers available for this viewer's stream as
911    /// `(key, measured_video_bitrate_bps)` — the base (the requested
912    /// `stream_id`) plus any `stream_id~rid` siblings the WHIP simulcast demux
913    /// published. The per-layer bitrate is the live ~1s QoS window, the input to
914    /// [`select_layer`]. When the publisher is not simulcast this returns just the
915    /// base layer, so layer selection is a no-op.
916    fn discover_layers(&self) -> Vec<(StreamKey, u64)> {
917        let app = &self.key.app;
918        let base = self.key.stream_id.as_str();
919        let prefix = format!("{base}~");
920        let mut out = Vec::new();
921        let ids = self.playback.list_streams(app).unwrap_or_default();
922        for id in ids {
923            let s = id.as_str();
924            if s == base || s.starts_with(&prefix) {
925                let key = StreamKey::new(app.as_str(), s);
926                let bitrate = self
927                    .playback
928                    .get_stream(&key)
929                    .map(|h| h.qos().video_bitrate_bps)
930                    .unwrap_or(0);
931                out.push((key, bitrate));
932            }
933        }
934        // Guarantee the base is always present even if enumeration missed it.
935        if !out.iter().any(|(k, _)| k == &self.key) {
936            out.push((self.key.clone(), 0));
937        }
938        out
939    }
940
941    /// React to a viewer's RTCP compound packet.
942    ///
943    /// A PLI/FIR triggers fast recovery: re-send the cached config + the most
944    /// recent keyframe so the viewer paints immediately rather than waiting for
945    /// the next natural IDR. Receiver Reports are logged for QoS visibility;
946    /// NACK-driven retransmission is a separate (send-history) feature.
947    #[allow(clippy::too_many_arguments)]
948    async fn handle_feedback(
949        &self,
950        rtcp: &[u8],
951        kf_handle: &crate::bus::StreamHandle,
952        vcfg: Option<&Arc<MediaFrame>>,
953        last_keyframe: Option<&Arc<MediaFrame>>,
954        packetizer: &mut EgressPacketizer,
955        audio: &mut Option<OpusPacketizer>,
956        pkts: &mut Vec<Vec<u8>>,
957        vclock: &mut MonoClock,
958        aclock: &mut MonoClock,
959    ) -> Result<()> {
960        let mut refresh = false;
961        for fb in rtcp::parse_compound(rtcp) {
962            match fb {
963                rtcp::RtcpFeedback::Pli { .. } | rtcp::RtcpFeedback::Fir { .. } => refresh = true,
964                rtcp::RtcpFeedback::ReceiverReport {
965                    fraction_lost,
966                    cumulative_lost,
967                    jitter,
968                    ..
969                } => {
970                    tracing::trace!(
971                        stream = %self.key,
972                        fraction_lost,
973                        cumulative_lost,
974                        jitter,
975                        "WHEP egress: viewer receiver report",
976                    );
977                }
978                rtcp::RtcpFeedback::Nack { lost, .. } => {
979                    tracing::trace!(
980                        stream = %self.key,
981                        lost = lost.len(),
982                        "WHEP egress: viewer NACK (retransmission not yet implemented)",
983                    );
984                }
985                rtcp::RtcpFeedback::Remb { bitrate_bps, .. } => {
986                    tracing::trace!(
987                        stream = %self.key,
988                        bitrate_bps,
989                        "WHEP egress: viewer REMB bandwidth estimate",
990                    );
991                }
992            }
993        }
994        if refresh {
995            // Fast local recovery: re-send the cached config + most recent
996            // keyframe so the viewer paints without waiting for a natural IDR.
997            // The cached frames carry old timestamps, but `send_frame` runs them
998            // through `vclock`, which maps their stale time onto the current
999            // monotonic output — so the keyframe lands "now" and the viewer
1000            // accepts it (a raw backward timestamp would be dropped as stale).
1001            if let Some(cfg) = vcfg {
1002                self.send_frame(cfg, packetizer, audio, pkts, vclock, aclock)
1003                    .await?;
1004            }
1005            if let Some(kf) = last_keyframe {
1006                self.send_frame(kf, packetizer, audio, pkts, vclock, aclock)
1007                    .await?;
1008            }
1009            // Also relay upstream: ask the publisher for a fresh IDR, covering the
1010            // case where the cache has no usable keyframe (e.g. just after join).
1011            kf_handle.request_keyframe();
1012        }
1013        Ok(())
1014    }
1015
1016    /// Packetize one frame and send each RTP packet over the transport.
1017    ///
1018    /// Video is packetized by the connection's [`EgressPacketizer`]; Opus audio
1019    /// (when the viewer negotiated it) by the [`OpusPacketizer`]. A video frame
1020    /// whose codec the packetizer can't handle is skipped with a single warning
1021    /// per connection — an *observable* skip, never a silent drop. Non-Opus audio
1022    /// is skipped silently (a different audio codec is expected on many sources).
1023    #[allow(clippy::too_many_arguments)]
1024    async fn send_frame(
1025        &self,
1026        frame: &MediaFrame,
1027        packetizer: &mut EgressPacketizer,
1028        audio: &mut Option<OpusPacketizer>,
1029        pkts: &mut Vec<Vec<u8>>,
1030        vclock: &mut MonoClock,
1031        aclock: &mut MonoClock,
1032    ) -> Result<()> {
1033        if frame.is_audio() {
1034            if let Some(ap) = audio.as_mut() {
1035                if frame.codec == CodecId::Opus {
1036                    let ts = (aclock.map(frame.dts).max(0) as u64).wrapping_mul(48) as u32; // ms → 48 kHz
1037                    ap.packetize_into(&frame.data, ts, pkts);
1038                    for packet in pkts.iter() {
1039                        self.transport.send_rtp(packet).await?;
1040                    }
1041                }
1042            }
1043            return Ok(());
1044        }
1045        if !frame.is_video() {
1046            return Ok(());
1047        }
1048        let ts_ms = vclock.map(frame.dts);
1049        if packetizer.packetize_into(frame, ts_ms, pkts) {
1050            for packet in pkts.iter() {
1051                self.transport.send_rtp(packet).await?;
1052            }
1053        } else {
1054            use std::sync::atomic::Ordering;
1055            if !self.warned_unsupported.swap(true, Ordering::Relaxed) {
1056                tracing::warn!(
1057                    stream = %self.key,
1058                    codec = ?frame.codec,
1059                    "WHEP egress: unsupported video codec; frames skipped",
1060                );
1061            }
1062        }
1063        Ok(())
1064    }
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    use crate::bus::PlaybackRegistry;
1071    use std::sync::Arc;
1072    use tokio::sync::Mutex;
1073
1074    #[test]
1075    fn mono_clock_is_strictly_increasing_through_glitches() {
1076        let mut c = MonoClock::new();
1077        // Normal cadence, a B-frame-style backward step, a duplicate, and a large
1078        // forward discontinuity — the output must never regress.
1079        let inputs = [
1080            1000, 1033, 1066, 1050, // backward (reorder)
1081            1100, 1100, // duplicate
1082            1133, 50_000, // huge jump (reset)
1083            50_033, 50_066,
1084        ];
1085        let mut prev = i64::MIN;
1086        for ms in inputs {
1087            let out = c.map(ms);
1088            assert!(out > prev, "output regressed: {out} after {prev}");
1089            prev = out;
1090        }
1091    }
1092
1093    #[test]
1094    fn mono_clock_passes_through_steady_cadence() {
1095        let mut c = MonoClock::new();
1096        // A clean 30 fps stream maps 1:1 (deltas preserved) after the first frame.
1097        assert_eq!(c.map(0), 0);
1098        assert_eq!(c.map(33), 33);
1099        assert_eq!(c.map(66), 66);
1100    }
1101
1102    /// A fake transport that replays a fixed RTP script and records what is sent.
1103    struct FakeTransport {
1104        packets: Mutex<std::collections::VecDeque<Vec<u8>>>,
1105        rtcp: Mutex<Vec<Vec<u8>>>,
1106        sent_rtp: Mutex<Vec<Vec<u8>>>,
1107        /// Inbound viewer RTCP script (WHEP), drained by `recv_rtcp`.
1108        rtcp_in: Mutex<std::collections::VecDeque<Vec<u8>>>,
1109        /// When the script drains, stay open (block) instead of yielding `None`,
1110        /// so a spawned `pump` keeps its publish sessions alive for inspection.
1111        keep_open: bool,
1112    }
1113
1114    impl FakeTransport {
1115        fn with_packets(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
1116            Self {
1117                packets: Mutex::new(packets),
1118                rtcp: Mutex::new(Vec::new()),
1119                sent_rtp: Mutex::new(Vec::new()),
1120                rtcp_in: Mutex::new(Default::default()),
1121                keep_open: false,
1122            }
1123        }
1124
1125        fn with_packets_keep_open(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
1126            Self {
1127                keep_open: true,
1128                ..Self::with_packets(packets)
1129            }
1130        }
1131
1132        /// A kept-open transport that delivers `rtcp` packets to the egress feedback
1133        /// loop (then blocks), for exercising WHEP viewer-feedback handling.
1134        fn with_inbound_rtcp(rtcp: std::collections::VecDeque<Vec<u8>>) -> Self {
1135            Self {
1136                keep_open: true,
1137                rtcp_in: Mutex::new(rtcp),
1138                ..Self::with_packets(Default::default())
1139            }
1140        }
1141    }
1142
1143    #[async_trait]
1144    impl DtlsSrtpTransport for FakeTransport {
1145        fn fingerprint(&self) -> String {
1146            "sha-256 AA:BB".into()
1147        }
1148        fn ice_credentials(&self) -> (String, String) {
1149            ("ufrag".into(), "pwd".into())
1150        }
1151        async fn recv_rtp(&self) -> Option<Vec<u8>> {
1152            match self.packets.lock().await.pop_front() {
1153                Some(p) => Some(p),
1154                None if self.keep_open => std::future::pending().await,
1155                None => None,
1156            }
1157        }
1158        async fn send_rtp(&self, packet: &[u8]) -> Result<()> {
1159            self.sent_rtp.lock().await.push(packet.to_vec());
1160            Ok(())
1161        }
1162        async fn send_rtcp(&self, packet: &[u8]) -> Result<()> {
1163            self.rtcp.lock().await.push(packet.to_vec());
1164            Ok(())
1165        }
1166        async fn recv_rtcp(&self) -> Option<Vec<u8>> {
1167            match self.rtcp_in.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    }
1174
1175    fn rtp_packet(seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
1176        rtp_packet_pt(96, seq, ts, marker, payload)
1177    }
1178
1179    fn rtp_packet_pt(pt: u8, seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
1180        let mut p = vec![0x80, if marker { 0x80 | pt } else { pt & 0x7F }];
1181        p.extend_from_slice(&seq.to_be_bytes());
1182        p.extend_from_slice(&ts.to_be_bytes());
1183        p.extend_from_slice(&[0, 0, 0, 7]);
1184        p.extend_from_slice(payload);
1185        p
1186    }
1187
1188    /// An RTP packet tagged with a one-byte RID header extension `(ext_id, rid)`.
1189    fn rtp_with_rid(ext_id: u8, rid: &str, seq: u16, marker: bool, payload: &[u8]) -> Vec<u8> {
1190        let mut p = vec![0x90, if marker { 0x80 | 96 } else { 96 }]; // X bit + PT 96
1191        p.extend_from_slice(&seq.to_be_bytes());
1192        p.extend_from_slice(&0u32.to_be_bytes()); // ts
1193        p.extend_from_slice(&[0, 0, 0, 7]); // ssrc
1194        p.extend_from_slice(&0xBEDEu16.to_be_bytes()); // one-byte ext profile
1195        let mut ext = vec![(ext_id << 4) | (rid.len() as u8 - 1)];
1196        ext.extend_from_slice(rid.as_bytes());
1197        while ext.len() % 4 != 0 {
1198            ext.push(0);
1199        }
1200        p.extend_from_slice(&((ext.len() / 4) as u16).to_be_bytes());
1201        p.extend_from_slice(&ext);
1202        p.extend_from_slice(payload);
1203        p
1204    }
1205
1206    /// Simulcast WHIP: two RID layers (`q`, `h`) are demultiplexed onto distinct
1207    /// streams — the base layer to the requested key, the other to `<stream>~h`.
1208    #[tokio::test]
1209    async fn pump_routes_simulcast_layers_to_per_layer_streams() {
1210        let engine = crate::Engine::builder()
1211            .application(crate::AppSpec::new("live").gop_cache(4))
1212            .build();
1213        let ctx = IngestContext::new(engine.clone());
1214        let offer = "v=0\r\n\
1215o=- 0 0 IN IP4 0.0.0.0\r\n\
1216m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
1217a=mid:0\r\n\
1218a=sendonly\r\n\
1219a=rtpmap:96 H264/90000\r\n\
1220a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid\r\n\
1221a=rid:q send\r\n\
1222a=rid:h send\r\n\
1223a=simulcast:send q;h\r\n";
1224
1225        // One IDR per layer, each tagged with its rid.
1226        let mut q = std::collections::VecDeque::new();
1227        q.push_back(rtp_with_rid(4, "q", 1, true, &[0x65, 0x11]));
1228        q.push_back(rtp_with_rid(4, "h", 2, true, &[0x65, 0x22]));
1229        let transport = Arc::new(FakeTransport::with_packets_keep_open(q));
1230
1231        let endpoint = WhipEndpoint::new(ctx);
1232        let (resource, _answer) = endpoint
1233            .accept_offer(offer, StreamKey::new("live", "cam"), transport)
1234            .unwrap();
1235        let pump = tokio::spawn(resource.pump());
1236
1237        // Base layer `q` → requested key; layer `h` → `cam~h`.
1238        let base = wait_for_stream(&engine, &StreamKey::new("live", "cam")).await;
1239        let high = wait_for_stream(&engine, &StreamKey::new("live", "cam~h")).await;
1240        assert!(base, "base simulcast layer published to the requested key");
1241        assert!(high, "second simulcast layer published to a per-rid key");
1242
1243        pump.abort();
1244    }
1245
1246    async fn wait_for_stream(engine: &Arc<crate::Engine>, key: &StreamKey) -> bool {
1247        for _ in 0..200 {
1248            if engine.get_stream(key).is_ok() {
1249                return true;
1250            }
1251            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1252        }
1253        false
1254    }
1255
1256    #[tokio::test]
1257    async fn accept_offer_builds_answer_with_transport_credentials() {
1258        let engine = crate::Engine::builder()
1259            .application(crate::AppSpec::new("live"))
1260            .build();
1261        let endpoint = WhipEndpoint::new(IngestContext::new(engine));
1262        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1263        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";
1264        let (_res, answer) = endpoint
1265            .accept_offer(offer, StreamKey::new("live", "web"), transport)
1266            .unwrap();
1267        assert!(answer.contains("a=ice-ufrag:ufrag"));
1268        assert!(answer.contains("a=fingerprint:sha-256 AA:BB"));
1269        assert!(answer.contains("a=setup:passive"));
1270    }
1271
1272    #[tokio::test]
1273    async fn pump_publishes_idr_then_releases_slot() {
1274        let engine = crate::Engine::builder()
1275            .application(crate::AppSpec::new("live").gop_cache(4))
1276            .build();
1277        let key = StreamKey::new("live", "web");
1278        let ctx = IngestContext::new(engine.clone());
1279
1280        let mut q = std::collections::VecDeque::new();
1281        q.push_back(rtp_packet(1, 0, true, &[0x65, 0x11])); // single IDR, marker
1282        let transport = Arc::new(FakeTransport::with_packets(q));
1283
1284        let resource = WhipResource {
1285            ctx,
1286            key: key.clone(),
1287            transport,
1288            video_pt: 96,
1289            audio_pt: None,
1290            rid_ext_id: None,
1291            simulcast_rids: Vec::new(),
1292        };
1293        resource.pump().await.unwrap();
1294
1295        // A complete keyframe was published, so no PLI was needed; the publish
1296        // slot is released once the transport drained.
1297        assert!(engine.get_stream(&key).is_err());
1298    }
1299
1300    #[tokio::test]
1301    async fn pump_requests_keyframe_on_a_depacketize_gap() {
1302        let engine = crate::Engine::builder()
1303            .application(crate::AppSpec::new("live").gop_cache(4))
1304            .build();
1305        let ctx = IngestContext::new(engine);
1306
1307        // A mid FU-A fragment with no start bit forces an OutOfOrder error → PLI.
1308        let mut q = std::collections::VecDeque::new();
1309        q.push_back(rtp_packet(1, 0, false, &[0x7C, 0x05, 0x11])); // FU-A, S=0
1310        let transport = Arc::new(FakeTransport::with_packets(q));
1311
1312        let resource = WhipResource {
1313            ctx,
1314            key: StreamKey::new("live", "web2"),
1315            transport: transport.clone(),
1316            video_pt: 96,
1317            audio_pt: None,
1318            rid_ext_id: None,
1319            simulcast_rids: Vec::new(),
1320        };
1321        resource.pump().await.unwrap();
1322        assert!(
1323            !transport.rtcp.lock().await.is_empty(),
1324            "a PLI was sent after the depacketize gap"
1325        );
1326    }
1327
1328    /// WHIP audio: an RTP packet on the negotiated Opus PT is published as an
1329    /// Opus audio frame (one packet per frame, 48 kHz → ms PTS), routed away from
1330    /// the H.264 depacketizer.
1331    #[tokio::test]
1332    async fn pump_routes_opus_audio_onto_the_bus() {
1333        let engine = crate::Engine::builder()
1334            .application(crate::AppSpec::new("live").gop_cache(8))
1335            .build();
1336        let key = StreamKey::new("live", "av");
1337        let ctx = IngestContext::new(engine.clone());
1338
1339        // Subscribe before pumping so we observe the published audio frame.
1340        let handle = engine.get_stream(&key);
1341        assert!(handle.is_err(), "stream not live until pump opens publish");
1342
1343        let mut q = std::collections::VecDeque::new();
1344        // PT 111 (Opus), ts 4800 → 100 ms; payload is an opaque Opus packet.
1345        q.push_back(rtp_packet_pt(111, 7, 4800, true, &[0xAA, 0xBB, 0xCC]));
1346        let transport = Arc::new(FakeTransport::with_packets(q));
1347
1348        let resource = WhipResource {
1349            ctx,
1350            key: key.clone(),
1351            transport,
1352            video_pt: 96,
1353            audio_pt: Some(111),
1354            rid_ext_id: None,
1355            simulcast_rids: Vec::new(),
1356        };
1357        // Capture frames via a parallel subscription opened once publishing starts.
1358        let pump = tokio::spawn(async move { resource.pump().await });
1359        // Give the pump a moment to open the publish + emit, then drain.
1360        let _ = pump.await.unwrap();
1361        // The stream closed cleanly after the single packet (no panic, no PLI:
1362        // audio never drives keyframe requests).
1363        assert!(engine.get_stream(&key).is_err());
1364    }
1365
1366    #[tokio::test]
1367    async fn whep_egress_packetizes_published_frames_as_rtp() {
1368        use crate::FrameFlags;
1369        let engine = crate::Engine::builder()
1370            .application(crate::AppSpec::new("live").gop_cache(8))
1371            .build();
1372        let key = StreamKey::new("live", "show");
1373
1374        // Publish a config + keyframe into the stream via an ingest session.
1375        let ctx = IngestContext::new(engine.clone());
1376        let session = ctx.open_publish(key.clone()).await.unwrap();
1377        let mut cfg = MediaFrame::new_video(
1378            0,
1379            0,
1380            bytes::Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42]),
1381            CodecId::H264,
1382            false,
1383        );
1384        cfg.flags |= FrameFlags::CONFIG;
1385        session.publish_frame(cfg).unwrap();
1386        session
1387            .publish_frame(MediaFrame::new_video(
1388                10,
1389                10,
1390                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x99]),
1391                CodecId::H264,
1392                true,
1393            ))
1394            .unwrap();
1395
1396        // A WHEP viewer subscribes and pumps; the bus closes when we finish().
1397        let whep = WhepEndpoint::new(engine.clone());
1398        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1399        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1400        let (resource, answer) = whep
1401            .accept_offer(offer, key.clone(), transport.clone())
1402            .unwrap();
1403        assert!(answer.contains("a=sendonly"), "WHEP answer is sendonly");
1404
1405        let pump = tokio::spawn(resource.pump());
1406        // Let the instant-start replay (config + keyframe) flush, then end the stream.
1407        for _ in 0..32 {
1408            if !transport.sent_rtp.lock().await.is_empty() {
1409                break;
1410            }
1411            tokio::task::yield_now().await;
1412        }
1413        session.finish().await.unwrap();
1414        let _ = pump.await.unwrap();
1415
1416        let sent = transport.sent_rtp.lock().await;
1417        assert!(!sent.is_empty(), "egress sent RTP packets");
1418        // The packets parse as RTP with our payload type.
1419        let h = RtpHeader::parse(&sent[0]).unwrap();
1420        assert_eq!(h.payload_type, 96);
1421    }
1422
1423    /// The WHEP egress gate denies a request whose token the gate rejects, and
1424    /// permits when no gate is installed (permit-all, matching RTSP/SRT egress).
1425    #[tokio::test]
1426    async fn whep_gate_denies_and_permits() {
1427        let engine = crate::Engine::builder()
1428            .application(crate::AppSpec::new("live"))
1429            .build();
1430        let key = StreamKey::new("live", "show");
1431        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1432        let transport = || Arc::new(FakeTransport::with_packets(Default::default()));
1433
1434        // Gate that allows only the token "good".
1435        let gate: crate::auth::EgressGate = Arc::new(|_key, token, _peer| {
1436            Box::pin(async move { token.as_deref() == Some("good") })
1437        });
1438        let whep = WhepEndpoint::new(engine.clone()).with_gate(gate);
1439
1440        // Wrong/absent token → denied.
1441        assert!(whep
1442            .accept_offer_gated(offer, key.clone(), None, None, transport())
1443            .await
1444            .is_err());
1445        // Correct token → allowed.
1446        assert!(whep
1447            .accept_offer_gated(offer, key.clone(), Some("good".into()), None, transport())
1448            .await
1449            .is_ok());
1450
1451        // No gate installed → permit-all even without a token.
1452        let open = WhepEndpoint::new(engine.clone());
1453        assert!(open
1454            .accept_offer_gated(offer, key.clone(), None, None, transport())
1455            .await
1456            .is_ok());
1457    }
1458
1459    /// WHEP viewer feedback: a PLI arriving over `recv_rtcp` makes the egress
1460    /// re-send the cached config + most recent keyframe for fast recovery, so the
1461    /// keyframe NAL is transmitted again after the initial instant-start replay.
1462    #[tokio::test]
1463    async fn whep_egress_resends_keyframe_on_viewer_pli() {
1464        use crate::FrameFlags;
1465        let engine = crate::Engine::builder()
1466            .application(crate::AppSpec::new("live").gop_cache(8))
1467            .build();
1468        let key = StreamKey::new("live", "fb");
1469
1470        let ctx = IngestContext::new(engine.clone());
1471        let session = ctx.open_publish(key.clone()).await.unwrap();
1472        let mut cfg = MediaFrame::new_video(
1473            0,
1474            0,
1475            bytes::Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42]),
1476            CodecId::H264,
1477            false,
1478        );
1479        cfg.flags |= FrameFlags::CONFIG;
1480        session.publish_frame(cfg).unwrap();
1481        session
1482            .publish_frame(MediaFrame::new_video(
1483                10,
1484                10,
1485                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x99]),
1486                CodecId::H264,
1487                true,
1488            ))
1489            .unwrap();
1490
1491        // The viewer's feedback script: one PLI, delivered once the loop starts.
1492        let mut script = std::collections::VecDeque::new();
1493        script.push_back(rtcp::build_pli(0, 0));
1494        let transport = Arc::new(FakeTransport::with_inbound_rtcp(script));
1495
1496        let whep = WhepEndpoint::new(engine.clone());
1497        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1498        let (resource, _) = whep
1499            .accept_offer(offer, key.clone(), transport.clone())
1500            .unwrap();
1501        let pump = tokio::spawn(resource.pump());
1502
1503        // Wait until the keyframe NAL (0x65) has been sent twice: once on the
1504        // instant-start replay, once again from the PLI-triggered refresh.
1505        let count_keyframes = |pkts: &[Vec<u8>]| {
1506            pkts.iter()
1507                .filter(|p| {
1508                    RtpHeader::parse(p)
1509                        .map(|h| p[h.payload_offset..].windows(1).any(|w| w[0] == 0x65))
1510                        .unwrap_or(false)
1511                })
1512                .count()
1513        };
1514        let mut refreshed = false;
1515        for _ in 0..500 {
1516            if count_keyframes(&transport.sent_rtp.lock().await) >= 2 {
1517                refreshed = true;
1518                break;
1519            }
1520            // A real (short) sleep lets the spawned pump task make progress and
1521            // its timer-driven branches advance, instead of racing a yield budget.
1522            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1523        }
1524        session.finish().await.unwrap();
1525        let _ = pump.await.unwrap();
1526        assert!(refreshed, "viewer PLI re-sent the keyframe");
1527    }
1528
1529    #[test]
1530    fn select_layer_picks_fit_with_hysteresis() {
1531        let k = |s: &str| StreamKey::new("live", s);
1532        // Three layers: low 300k, mid 800k, high 2.5M.
1533        let layers = vec![
1534            (k("show"), 300_000u64),
1535            (k("show~h"), 800_000),
1536            (k("show~f"), 2_500_000),
1537        ];
1538
1539        // Plenty of bandwidth, currently on low → up-switch to high (>25% headroom
1540        // over 2.5M needs >= 3.125M; give 4M).
1541        assert_eq!(
1542            select_layer(&layers, Some(4_000_000), &k("show")),
1543            k("show~f")
1544        );
1545
1546        // Marginal bandwidth just above mid's bitrate but below the 25% headroom
1547        // for up-switching from low → no up-switch (stays low).
1548        assert_eq!(select_layer(&layers, Some(820_000), &k("show")), k("show"));
1549
1550        // On high, bandwidth collapses below 95% of high → down-switch to the best
1551        // fit (mid at 800k fits 900k).
1552        assert_eq!(
1553            select_layer(&layers, Some(900_000), &k("show~f")),
1554            k("show~h")
1555        );
1556
1557        // No estimate yet → stay on the current layer.
1558        assert_eq!(select_layer(&layers, None, &k("show~h")), k("show~h"));
1559
1560        // Nothing fits (estimate below the floor) → the floor layer.
1561        assert_eq!(
1562            select_layer(&layers, Some(100_000), &k("show~f")),
1563            k("show")
1564        );
1565
1566        // Single layer (no simulcast) → always itself.
1567        let one = vec![(k("solo"), 0u64)];
1568        assert_eq!(select_layer(&one, Some(5_000_000), &k("solo")), k("solo"));
1569    }
1570
1571    /// WHEP layer discovery: the base stream plus its `~rid` simulcast siblings
1572    /// are enumerated for adaptive-bitrate selection; unrelated streams are not.
1573    #[tokio::test]
1574    async fn discover_layers_lists_base_and_simulcast_siblings() {
1575        let engine = crate::Engine::builder()
1576            .application(crate::AppSpec::new("live").gop_cache(4))
1577            .build();
1578        let ctx = IngestContext::new(engine.clone());
1579        // Publish a base layer, two simulcast siblings, and an unrelated stream.
1580        for id in ["show", "show~h", "show~f", "other"] {
1581            let s = ctx.open_publish(StreamKey::new("live", id)).await.unwrap();
1582            s.publish_frame(MediaFrame::new_video(
1583                0,
1584                0,
1585                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65]),
1586                CodecId::H264,
1587                true,
1588            ))
1589            .unwrap();
1590            std::mem::forget(s); // keep the streams live for the listing
1591        }
1592
1593        let whep = WhepEndpoint::new(engine.clone());
1594        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1595        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1596        let (resource, _) = whep
1597            .accept_offer(offer, StreamKey::new("live", "show"), transport)
1598            .unwrap();
1599
1600        let mut ids: Vec<String> = resource
1601            .discover_layers()
1602            .into_iter()
1603            .map(|(k, _)| k.stream_id.as_str().to_string())
1604            .collect();
1605        ids.sort();
1606        assert_eq!(ids, vec!["show", "show~f", "show~h"]);
1607    }
1608
1609    /// The upstream keyframe backchannel: a viewer PLI on the WHEP egress calls
1610    /// `StreamHandle::request_keyframe`, which the publisher's ingest loop awaits
1611    /// via `keyframe_requested` — so a browser PLI reaches the WHIP publisher.
1612    #[tokio::test]
1613    async fn request_keyframe_signals_the_publishers_handle() {
1614        let engine = crate::Engine::builder()
1615            .application(crate::AppSpec::new("live").gop_cache(8))
1616            .build();
1617        let key = StreamKey::new("live", "kf");
1618
1619        let ctx = IngestContext::new(engine.clone());
1620        let session = ctx.open_publish(key.clone()).await.unwrap();
1621        // The publisher's loop awaits a keyframe request on its handle.
1622        let pub_handle = session.handle().clone();
1623        let waiter = tokio::spawn(async move {
1624            tokio::time::timeout(
1625                std::time::Duration::from_secs(2),
1626                pub_handle.keyframe_requested(),
1627            )
1628            .await
1629        });
1630
1631        // A playback consumer resolves the *same* stream and requests a keyframe.
1632        let view_handle = engine.get_stream(&key).unwrap();
1633        // Give the waiter a moment to park on `notified()` before signaling.
1634        tokio::task::yield_now().await;
1635        view_handle.request_keyframe();
1636
1637        assert!(waiter.await.unwrap().is_ok(), "publisher saw the request");
1638    }
1639
1640    /// WHEP audio: when the viewer's offer carries an Opus audio line, published
1641    /// Opus audio frames are RTP-packetized on the audio payload type and sent.
1642    #[tokio::test]
1643    async fn whep_egress_packetizes_opus_audio() {
1644        let engine = crate::Engine::builder()
1645            .application(crate::AppSpec::new("live").gop_cache(8))
1646            .build();
1647        let key = StreamKey::new("live", "aud");
1648
1649        let ctx = IngestContext::new(engine.clone());
1650        let session = ctx.open_publish(key.clone()).await.unwrap();
1651        // A keyframe (so the GOP replay has video) plus an Opus audio frame.
1652        session
1653            .publish_frame(MediaFrame::new_video(
1654                0,
1655                0,
1656                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]),
1657                CodecId::H264,
1658                true,
1659            ))
1660            .unwrap();
1661        session
1662            .publish_frame(MediaFrame::new_audio(
1663                20,
1664                bytes::Bytes::from_static(&[0xDE, 0xAD, 0xBE, 0xEF]),
1665                CodecId::Opus,
1666            ))
1667            .unwrap();
1668
1669        let whep = WhepEndpoint::new(engine.clone());
1670        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1671        // Offer with both video and Opus audio (PT 111).
1672        let offer = "v=0\r\n\
1673m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n\
1674m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=rtpmap:111 opus/48000/2\r\n";
1675        let (resource, _answer) = whep
1676            .accept_offer(offer, key.clone(), transport.clone())
1677            .unwrap();
1678
1679        let pump = tokio::spawn(resource.pump());
1680        for _ in 0..64 {
1681            if transport
1682                .sent_rtp
1683                .lock()
1684                .await
1685                .iter()
1686                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111))
1687            {
1688                break;
1689            }
1690            tokio::task::yield_now().await;
1691        }
1692        session.finish().await.unwrap();
1693        let _ = pump.await.unwrap();
1694
1695        let sent = transport.sent_rtp.lock().await;
1696        assert!(
1697            sent.iter()
1698                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111)),
1699            "egress sent an Opus audio RTP packet on PT 111"
1700        );
1701    }
1702
1703    #[tokio::test]
1704    async fn whep_egress_packetizes_vp9_frames() {
1705        let engine = crate::Engine::builder()
1706            .application(crate::AppSpec::new("live").gop_cache(8))
1707            .build();
1708        let key = StreamKey::new("live", "vp9");
1709
1710        // Publish a VP9 keyframe (no config AU — codec is inferred from the frame).
1711        let ctx = IngestContext::new(engine.clone());
1712        let session = ctx.open_publish(key.clone()).await.unwrap();
1713        let frame_data = bytes::Bytes::from_static(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
1714        session
1715            .publish_frame(MediaFrame::new_video(
1716                0,
1717                0,
1718                frame_data.clone(),
1719                CodecId::VP9,
1720                true,
1721            ))
1722            .unwrap();
1723
1724        let whep = WhepEndpoint::new(engine.clone());
1725        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1726        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 VP9/90000\r\n";
1727        let (resource, _answer) = whep
1728            .accept_offer(offer, key.clone(), transport.clone())
1729            .unwrap();
1730
1731        let pump = tokio::spawn(resource.pump());
1732        for _ in 0..32 {
1733            if !transport.sent_rtp.lock().await.is_empty() {
1734                break;
1735            }
1736            tokio::task::yield_now().await;
1737        }
1738        session.finish().await.unwrap();
1739        let _ = pump.await.unwrap();
1740
1741        // The egress RTP round-trips back to the original VP9 frame.
1742        let sent = transport.sent_rtp.lock().await;
1743        assert!(!sent.is_empty(), "VP9 egress sent RTP packets");
1744        let mut depack = crate::protocol::rtp::Vp9Depacketizer::new();
1745        let mut out = None;
1746        for p in sent.iter() {
1747            let h = RtpHeader::parse(p).unwrap();
1748            if let Some(f) = depack
1749                .push(&p[h.payload_offset..], h.marker, h.timestamp)
1750                .unwrap()
1751            {
1752                out = Some(f);
1753            }
1754        }
1755        let out = out.expect("VP9 frame completed");
1756        assert_eq!(&out.data[..], &frame_data[..], "VP9 frame reconstructed");
1757        assert!(out.keyframe);
1758    }
1759}