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}
431
432impl WhepEndpoint {
433    /// Build an endpoint that serves media from `playback` (e.g. an `Arc<Engine>`).
434    pub fn new(playback: Arc<dyn PlaybackRegistry>) -> Self {
435        Self { playback }
436    }
437
438    /// Handle a WHEP `POST`: validate the offer and mint a `sendonly` answer.
439    /// Returns the resource handle (to `pump`) and the answer SDP.
440    pub fn accept_offer(
441        &self,
442        offer_sdp: &str,
443        key: StreamKey,
444        transport: Arc<dyn DtlsSrtpTransport>,
445    ) -> Result<(WhepResource, String)> {
446        let offer = SdpOffer::parse(offer_sdp)
447            .ok_or_else(|| crate::StreamError::protocol("malformed SDP offer"))?;
448        // WHEP egress: we send to the viewer → sendonly answer (transport-owned).
449        let answer = transport.answer(offer_sdp, MediaDirection::SendOnly);
450        let resource = WhepResource {
451            playback: Arc::clone(&self.playback),
452            key,
453            transport,
454            payload_type: offer.payload_type,
455            audio_payload_type: offer.audio_payload_type,
456            warned_unsupported: std::sync::atomic::AtomicBool::new(false),
457        };
458        Ok((resource, answer))
459    }
460}
461
462/// An accepted WHEP connection: streams one live stream out to the viewer as RTP
463/// until the stream ends or the peer disconnects.
464pub struct WhepResource {
465    playback: Arc<dyn PlaybackRegistry>,
466    key: StreamKey,
467    transport: Arc<dyn DtlsSrtpTransport>,
468    payload_type: u8,
469    /// Negotiated Opus audio payload type, when the viewer's offer carried audio.
470    /// `None` disables audio egress (video-only viewer or non-Opus source).
471    audio_payload_type: Option<u8>,
472    /// Set once we have warned about an unsupported egress video codec, so the
473    /// log line fires a single time per connection instead of per frame.
474    warned_unsupported: std::sync::atomic::AtomicBool,
475}
476
477/// The RTP payload format chosen for a WHEP egress connection, selected from the
478/// stream's video codec. Each variant only packetizes frames of its own codec;
479/// a mismatched frame yields `None` (skipped, observably) from
480/// [`packetize`](EgressPacketizer::packetize).
481enum EgressPacketizer {
482    /// NAL codecs — H.264 (RFC 6184) or H.265 (RFC 7798).
483    Nal { p: RtpPacketizer, codec: CodecId },
484    /// VP9 (draft-ietf-payload-vp9).
485    Vp9(Vp9Packetizer),
486    /// AV1 (AOMedia RTP).
487    #[cfg(feature = "codec-av1")]
488    Av1(Av1Packetizer),
489}
490
491impl EgressPacketizer {
492    /// Build the packetizer for `codec`. Codecs without an RTP payload format in
493    /// this build fall back to an H.264 NAL packetizer, so their frames are
494    /// skipped observably rather than mis-framed.
495    fn for_codec(payload_type: u8, ssrc: u32, mtu: usize, codec: CodecId) -> Self {
496        match codec {
497            CodecId::H265 => EgressPacketizer::Nal {
498                p: RtpPacketizer::new_h265(payload_type, ssrc, mtu),
499                codec: CodecId::H265,
500            },
501            CodecId::VP9 => EgressPacketizer::Vp9(Vp9Packetizer::new(payload_type, ssrc, mtu)),
502            #[cfg(feature = "codec-av1")]
503            CodecId::AV1 => EgressPacketizer::Av1(Av1Packetizer::new(payload_type, ssrc, mtu)),
504            _ => EgressPacketizer::Nal {
505                p: RtpPacketizer::new(payload_type, ssrc, mtu),
506                codec: CodecId::H264,
507            },
508        }
509    }
510
511    /// Packetize one video frame at its 90 kHz timestamp into the recycled
512    /// `out` buffer, returning `true` if the frame's codec matched this
513    /// packetizer (and `false`, leaving `out` empty, when it did not).
514    fn packetize_into(&mut self, frame: &MediaFrame, out: &mut Vec<Vec<u8>>) -> bool {
515        // Clamp negative PTS to 0 before the u64 cast (otherwise it wraps to a
516        // huge timestamp and desynchronizes the receiver's clock).
517        let ts = (frame.pts.max(0) as u64).wrapping_mul(90) as u32; // ms → 90 kHz
518        match self {
519            EgressPacketizer::Nal { p, codec } if frame.codec == *codec => {
520                p.packetize_into(&frame.data, ts, out);
521                true
522            }
523            EgressPacketizer::Vp9(p) if frame.codec == CodecId::VP9 => {
524                p.packetize_into(&frame.data, ts, frame.is_keyframe(), out);
525                true
526            }
527            #[cfg(feature = "codec-av1")]
528            EgressPacketizer::Av1(p) if frame.codec == CodecId::AV1 => {
529                p.packetize_into(&frame.data, ts, out);
530                true
531            }
532            _ => false,
533        }
534    }
535}
536
537/// Choose the best simulcast layer for a bandwidth `estimate`, with hysteresis
538/// to avoid flapping between adjacent layers.
539///
540/// `layers` is `(key, measured_bitrate_bps)` for every available layer (the base
541/// plus any `~rid` siblings); order does not matter. Rules:
542///
543/// * The lowest-bitrate layer is the always-available floor.
544/// * With no estimate yet, keep `current` (or fall to the floor if it vanished).
545/// * Otherwise target the highest layer that fits the estimate. **Up-switch**
546///   needs 25% headroom over the candidate's bitrate; **down-switch** triggers
547///   once the estimate drops below 95% of the current layer's bitrate (congestion
548///   is urgent, so it reacts faster than it climbs).
549/// * Layers with an unknown (0) bitrate are never up-switch targets — we can't
550///   tell if they fit — but the base floor is always eligible.
551fn select_layer(
552    layers: &[(StreamKey, u64)],
553    estimate: Option<u64>,
554    current: &StreamKey,
555) -> StreamKey {
556    if layers.is_empty() {
557        return current.clone();
558    }
559    // The floor: lowest measured bitrate (0/unknown sorts lowest, which is fine —
560    // an unmeasured single layer is still the only choice).
561    let floor = layers
562        .iter()
563        .min_by_key(|(_, bps)| *bps)
564        .map(|(k, _)| k.clone())
565        .unwrap();
566    let current_bps = layers.iter().find(|(k, _)| k == current).map(|(_, b)| *b);
567    let Some(estimate) = estimate else {
568        // No estimate: stay put if the current layer still exists, else the floor.
569        return if current_bps.is_some() {
570            current.clone()
571        } else {
572            floor
573        };
574    };
575
576    // Highest layer with a known bitrate that fits the estimate.
577    let desired = layers
578        .iter()
579        .filter(|(_, bps)| *bps > 0 && *bps <= estimate)
580        .max_by_key(|(_, bps)| *bps);
581    let Some((desired_key, desired_bps)) = desired else {
582        return floor; // nothing fits → floor
583    };
584    let current_bps = match current_bps {
585        Some(b) => b,
586        None => return desired_key.clone(), // current gone → take the fit
587    };
588    if *desired_bps > current_bps {
589        // Up-switch only with 25% headroom over the candidate.
590        if estimate >= desired_bps.saturating_mul(5) / 4 {
591            return desired_key.clone();
592        }
593    } else if *desired_bps < current_bps {
594        // Down-switch once the current layer no longer comfortably fits.
595        if estimate < current_bps.saturating_mul(19) / 20 {
596            return desired_key.clone();
597        }
598    }
599    current.clone()
600}
601
602impl WhepResource {
603    /// Drive egress: subscribe to the stream, replay the cached config + GOP for
604    /// an instant start, then packetize and send every published video frame.
605    /// Returns when the stream closes or the subscription lags out.
606    ///
607    /// The RTP payload format is selected from the stream's video codec: H.264
608    /// (RFC 6184), H.265 (RFC 7798), VP9, and AV1 (with `codec-av1`) are
609    /// packetized; other video codecs are skipped with a single warning per
610    /// connection, and audio is skipped.
611    pub async fn pump(self) -> Result<()> {
612        let handle = self.playback.get_stream(&self.key)?;
613        // SSRC derived from the key so retries are stable; real deployments may
614        // randomize per PeerConnection.
615        let ssrc = 0x5745_4850; // "WEHP"
616        let mut sub = handle.subscribe_resilient();
617
618        // Instant start: send the cached config frame + GOP before live frames.
619        let (mut vcfg, _) = handle.cached_configs();
620        let replay = handle.replay_buffer();
621        // Keep a handle clone solely to relay viewer keyframe requests upstream to
622        // the publisher. A clone does not pin the bus open — `StreamHandle::close`
623        // empties the shared sender cell on publish-end regardless of clones.
624        // Reassigned on an adaptive-bitrate layer switch so keyframe requests
625        // target the layer actually being forwarded.
626        let mut kf_handle = handle.clone();
627        // Release the original handle once setup is done.
628        drop(handle);
629
630        // Adaptive bitrate: the layer currently forwarded to this viewer. Starts
631        // on the requested stream (the base) and may switch among simulcast
632        // siblings as the transport's bandwidth estimate changes.
633        let mut current_key = self.key.clone();
634
635        // Pick the payload format from the stream's video codec (config frame
636        // first, else the first replayed video frame; defaulting to H.264).
637        let video_codec = vcfg
638            .as_ref()
639            .map(|c| c.codec)
640            .or_else(|| replay.iter().find(|f| f.is_video()).map(|f| f.codec))
641            .unwrap_or(CodecId::H264);
642        let mut packetizer =
643            EgressPacketizer::for_codec(self.payload_type, ssrc, 1200, video_codec);
644        // Opus audio packetizer on a distinct SSRC, when the viewer offered audio.
645        // Only Opus frames are sent (an AAC source's audio is skipped — a browser
646        // can't decode AAC over this Opus payload type).
647        let mut audio = self
648            .audio_payload_type
649            .map(|pt| OpusPacketizer::new(pt, 0x5745_4151)); // "WEAQ"
650
651        // Reused across frames so steady-state egress allocates no packet buffers.
652        let mut pkts: Vec<Vec<u8>> = Vec::new();
653
654        // Kept for fast local recovery: when a viewer requests a keyframe (PLI/FIR
655        // over RTCP), we re-send the config + the most recent keyframe instead of
656        // making the viewer wait for the next natural IDR.
657        if let Some(cfg) = vcfg.as_ref() {
658            self.send_frame(cfg, &mut packetizer, &mut audio, &mut pkts)
659                .await?;
660        }
661        let mut last_keyframe: Option<Arc<MediaFrame>> = None;
662        for frame in replay {
663            if frame.is_video() && frame.is_keyframe() {
664                last_keyframe = Some(frame.clone());
665            }
666            self.send_frame(&frame, &mut packetizer, &mut audio, &mut pkts)
667                .await?;
668        }
669
670        // Poll the live subscription, the viewer's RTCP feedback, and a periodic
671        // adaptive-bitrate tick together. Once `recv_rtcp` yields `None` (a
672        // transport with no upstream RTCP, or a closed one) we stop polling it via
673        // a never-ready future so the loop can't spin.
674        let mut rtcp_open = true;
675        let mut abr_tick = tokio::time::interval(std::time::Duration::from_secs(1));
676        abr_tick.tick().await; // consume the immediate first tick
677        loop {
678            let feedback = async {
679                if rtcp_open {
680                    self.transport.recv_rtcp().await
681                } else {
682                    std::future::pending().await
683                }
684            };
685            tokio::select! {
686                frame = sub.recv() => {
687                    let Some(frame) = frame else { break };
688                    if frame.is_video() && frame.is_keyframe() {
689                        last_keyframe = Some(frame.clone());
690                    }
691                    self.send_frame(&frame, &mut packetizer, &mut audio, &mut pkts)
692                        .await?;
693                }
694                rtcp = feedback => {
695                    match rtcp {
696                        Some(buf) => {
697                            self.handle_feedback(
698                                &buf,
699                                &kf_handle,
700                                vcfg.as_ref(),
701                                last_keyframe.as_ref(),
702                                &mut packetizer,
703                                &mut audio,
704                                &mut pkts,
705                            )
706                            .await?;
707                        }
708                        None => rtcp_open = false,
709                    }
710                }
711                _ = abr_tick.tick() => {
712                    // Re-evaluate which simulcast layer best fits the viewer's
713                    // current bandwidth estimate; switch the subscription if so.
714                    let layers = self.discover_layers();
715                    let estimate = self.transport.estimated_bitrate();
716                    let target = select_layer(&layers, estimate, &current_key);
717                    if target != current_key {
718                        if let Ok(next) = self.playback.get_stream(&target) {
719                            tracing::debug!(
720                                stream = %self.key, from = %current_key, to = %target,
721                                estimate_bps = estimate.unwrap_or(0),
722                                "WHEP egress: adaptive-bitrate layer switch",
723                            );
724                            sub = next.subscribe_resilient();
725                            vcfg = next.cached_configs().0;
726                            kf_handle = next.clone();
727                            current_key = target;
728                            // Resync the decoder on the new layer: re-send its
729                            // config and ask its publisher for a fresh keyframe.
730                            if let Some(cfg) = vcfg.as_ref() {
731                                self.send_frame(cfg, &mut packetizer, &mut audio, &mut pkts)
732                                    .await?;
733                            }
734                            last_keyframe = None;
735                            kf_handle.request_keyframe();
736                        }
737                    }
738                }
739            }
740        }
741        Ok(())
742    }
743
744    /// Enumerate the simulcast layers available for this viewer's stream as
745    /// `(key, measured_video_bitrate_bps)` — the base (the requested
746    /// `stream_id`) plus any `stream_id~rid` siblings the WHIP simulcast demux
747    /// published. The per-layer bitrate is the live ~1s QoS window, the input to
748    /// [`select_layer`]. When the publisher is not simulcast this returns just the
749    /// base layer, so layer selection is a no-op.
750    fn discover_layers(&self) -> Vec<(StreamKey, u64)> {
751        let app = &self.key.app;
752        let base = self.key.stream_id.as_str();
753        let prefix = format!("{base}~");
754        let mut out = Vec::new();
755        let ids = self.playback.list_streams(app).unwrap_or_default();
756        for id in ids {
757            let s = id.as_str();
758            if s == base || s.starts_with(&prefix) {
759                let key = StreamKey::new(app.as_str(), s);
760                let bitrate = self
761                    .playback
762                    .get_stream(&key)
763                    .map(|h| h.qos().video_bitrate_bps)
764                    .unwrap_or(0);
765                out.push((key, bitrate));
766            }
767        }
768        // Guarantee the base is always present even if enumeration missed it.
769        if !out.iter().any(|(k, _)| k == &self.key) {
770            out.push((self.key.clone(), 0));
771        }
772        out
773    }
774
775    /// React to a viewer's RTCP compound packet.
776    ///
777    /// A PLI/FIR triggers fast recovery: re-send the cached config + the most
778    /// recent keyframe so the viewer paints immediately rather than waiting for
779    /// the next natural IDR. Receiver Reports are logged for QoS visibility;
780    /// NACK-driven retransmission is a separate (send-history) feature.
781    #[allow(clippy::too_many_arguments)]
782    async fn handle_feedback(
783        &self,
784        rtcp: &[u8],
785        kf_handle: &crate::bus::StreamHandle,
786        vcfg: Option<&Arc<MediaFrame>>,
787        last_keyframe: Option<&Arc<MediaFrame>>,
788        packetizer: &mut EgressPacketizer,
789        audio: &mut Option<OpusPacketizer>,
790        pkts: &mut Vec<Vec<u8>>,
791    ) -> Result<()> {
792        let mut refresh = false;
793        for fb in rtcp::parse_compound(rtcp) {
794            match fb {
795                rtcp::RtcpFeedback::Pli { .. } | rtcp::RtcpFeedback::Fir { .. } => refresh = true,
796                rtcp::RtcpFeedback::ReceiverReport {
797                    fraction_lost,
798                    cumulative_lost,
799                    jitter,
800                    ..
801                } => {
802                    tracing::trace!(
803                        stream = %self.key,
804                        fraction_lost,
805                        cumulative_lost,
806                        jitter,
807                        "WHEP egress: viewer receiver report",
808                    );
809                }
810                rtcp::RtcpFeedback::Nack { lost, .. } => {
811                    tracing::trace!(
812                        stream = %self.key,
813                        lost = lost.len(),
814                        "WHEP egress: viewer NACK (retransmission not yet implemented)",
815                    );
816                }
817                rtcp::RtcpFeedback::Remb { bitrate_bps, .. } => {
818                    tracing::trace!(
819                        stream = %self.key,
820                        bitrate_bps,
821                        "WHEP egress: viewer REMB bandwidth estimate",
822                    );
823                }
824            }
825        }
826        if refresh {
827            // Fast local recovery: re-send the cached config + most recent
828            // keyframe so the viewer paints without waiting for a natural IDR.
829            if let Some(cfg) = vcfg {
830                self.send_frame(cfg, packetizer, audio, pkts).await?;
831            }
832            if let Some(kf) = last_keyframe {
833                self.send_frame(kf, packetizer, audio, pkts).await?;
834            }
835            // Also relay upstream: ask the publisher for a fresh IDR, covering the
836            // case where the cache has no usable keyframe (e.g. just after join).
837            kf_handle.request_keyframe();
838        }
839        Ok(())
840    }
841
842    /// Packetize one frame and send each RTP packet over the transport.
843    ///
844    /// Video is packetized by the connection's [`EgressPacketizer`]; Opus audio
845    /// (when the viewer negotiated it) by the [`OpusPacketizer`]. A video frame
846    /// whose codec the packetizer can't handle is skipped with a single warning
847    /// per connection — an *observable* skip, never a silent drop. Non-Opus audio
848    /// is skipped silently (a different audio codec is expected on many sources).
849    async fn send_frame(
850        &self,
851        frame: &MediaFrame,
852        packetizer: &mut EgressPacketizer,
853        audio: &mut Option<OpusPacketizer>,
854        pkts: &mut Vec<Vec<u8>>,
855    ) -> Result<()> {
856        if frame.is_audio() {
857            if let Some(ap) = audio.as_mut() {
858                if frame.codec == CodecId::Opus {
859                    let ts = (frame.pts.max(0) as u64).wrapping_mul(48) as u32; // ms → 48 kHz
860                    ap.packetize_into(&frame.data, ts, pkts);
861                    for packet in pkts.iter() {
862                        self.transport.send_rtp(packet).await?;
863                    }
864                }
865            }
866            return Ok(());
867        }
868        if !frame.is_video() {
869            return Ok(());
870        }
871        if packetizer.packetize_into(frame, pkts) {
872            for packet in pkts.iter() {
873                self.transport.send_rtp(packet).await?;
874            }
875        } else {
876            use std::sync::atomic::Ordering;
877            if !self.warned_unsupported.swap(true, Ordering::Relaxed) {
878                tracing::warn!(
879                    stream = %self.key,
880                    codec = ?frame.codec,
881                    "WHEP egress: unsupported video codec; frames skipped",
882                );
883            }
884        }
885        Ok(())
886    }
887}
888
889#[cfg(test)]
890mod tests {
891    use super::*;
892    use crate::bus::PlaybackRegistry;
893    use std::sync::Arc;
894    use tokio::sync::Mutex;
895
896    /// A fake transport that replays a fixed RTP script and records what is sent.
897    struct FakeTransport {
898        packets: Mutex<std::collections::VecDeque<Vec<u8>>>,
899        rtcp: Mutex<Vec<Vec<u8>>>,
900        sent_rtp: Mutex<Vec<Vec<u8>>>,
901        /// Inbound viewer RTCP script (WHEP), drained by `recv_rtcp`.
902        rtcp_in: Mutex<std::collections::VecDeque<Vec<u8>>>,
903        /// When the script drains, stay open (block) instead of yielding `None`,
904        /// so a spawned `pump` keeps its publish sessions alive for inspection.
905        keep_open: bool,
906    }
907
908    impl FakeTransport {
909        fn with_packets(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
910            Self {
911                packets: Mutex::new(packets),
912                rtcp: Mutex::new(Vec::new()),
913                sent_rtp: Mutex::new(Vec::new()),
914                rtcp_in: Mutex::new(Default::default()),
915                keep_open: false,
916            }
917        }
918
919        fn with_packets_keep_open(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
920            Self {
921                keep_open: true,
922                ..Self::with_packets(packets)
923            }
924        }
925
926        /// A kept-open transport that delivers `rtcp` packets to the egress feedback
927        /// loop (then blocks), for exercising WHEP viewer-feedback handling.
928        fn with_inbound_rtcp(rtcp: std::collections::VecDeque<Vec<u8>>) -> Self {
929            Self {
930                keep_open: true,
931                rtcp_in: Mutex::new(rtcp),
932                ..Self::with_packets(Default::default())
933            }
934        }
935    }
936
937    #[async_trait]
938    impl DtlsSrtpTransport for FakeTransport {
939        fn fingerprint(&self) -> String {
940            "sha-256 AA:BB".into()
941        }
942        fn ice_credentials(&self) -> (String, String) {
943            ("ufrag".into(), "pwd".into())
944        }
945        async fn recv_rtp(&self) -> Option<Vec<u8>> {
946            match self.packets.lock().await.pop_front() {
947                Some(p) => Some(p),
948                None if self.keep_open => std::future::pending().await,
949                None => None,
950            }
951        }
952        async fn send_rtp(&self, packet: &[u8]) -> Result<()> {
953            self.sent_rtp.lock().await.push(packet.to_vec());
954            Ok(())
955        }
956        async fn send_rtcp(&self, packet: &[u8]) -> Result<()> {
957            self.rtcp.lock().await.push(packet.to_vec());
958            Ok(())
959        }
960        async fn recv_rtcp(&self) -> Option<Vec<u8>> {
961            match self.rtcp_in.lock().await.pop_front() {
962                Some(p) => Some(p),
963                None if self.keep_open => std::future::pending().await,
964                None => None,
965            }
966        }
967    }
968
969    fn rtp_packet(seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
970        rtp_packet_pt(96, seq, ts, marker, payload)
971    }
972
973    fn rtp_packet_pt(pt: u8, seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
974        let mut p = vec![0x80, if marker { 0x80 | pt } else { pt & 0x7F }];
975        p.extend_from_slice(&seq.to_be_bytes());
976        p.extend_from_slice(&ts.to_be_bytes());
977        p.extend_from_slice(&[0, 0, 0, 7]);
978        p.extend_from_slice(payload);
979        p
980    }
981
982    /// An RTP packet tagged with a one-byte RID header extension `(ext_id, rid)`.
983    fn rtp_with_rid(ext_id: u8, rid: &str, seq: u16, marker: bool, payload: &[u8]) -> Vec<u8> {
984        let mut p = vec![0x90, if marker { 0x80 | 96 } else { 96 }]; // X bit + PT 96
985        p.extend_from_slice(&seq.to_be_bytes());
986        p.extend_from_slice(&0u32.to_be_bytes()); // ts
987        p.extend_from_slice(&[0, 0, 0, 7]); // ssrc
988        p.extend_from_slice(&0xBEDEu16.to_be_bytes()); // one-byte ext profile
989        let mut ext = vec![(ext_id << 4) | (rid.len() as u8 - 1)];
990        ext.extend_from_slice(rid.as_bytes());
991        while ext.len() % 4 != 0 {
992            ext.push(0);
993        }
994        p.extend_from_slice(&((ext.len() / 4) as u16).to_be_bytes());
995        p.extend_from_slice(&ext);
996        p.extend_from_slice(payload);
997        p
998    }
999
1000    /// Simulcast WHIP: two RID layers (`q`, `h`) are demultiplexed onto distinct
1001    /// streams — the base layer to the requested key, the other to `<stream>~h`.
1002    #[tokio::test]
1003    async fn pump_routes_simulcast_layers_to_per_layer_streams() {
1004        let engine = crate::Engine::builder()
1005            .application(crate::AppSpec::new("live").gop_cache(4))
1006            .build();
1007        let ctx = IngestContext::new(engine.clone());
1008        let offer = "v=0\r\n\
1009o=- 0 0 IN IP4 0.0.0.0\r\n\
1010m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
1011a=mid:0\r\n\
1012a=sendonly\r\n\
1013a=rtpmap:96 H264/90000\r\n\
1014a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid\r\n\
1015a=rid:q send\r\n\
1016a=rid:h send\r\n\
1017a=simulcast:send q;h\r\n";
1018
1019        // One IDR per layer, each tagged with its rid.
1020        let mut q = std::collections::VecDeque::new();
1021        q.push_back(rtp_with_rid(4, "q", 1, true, &[0x65, 0x11]));
1022        q.push_back(rtp_with_rid(4, "h", 2, true, &[0x65, 0x22]));
1023        let transport = Arc::new(FakeTransport::with_packets_keep_open(q));
1024
1025        let endpoint = WhipEndpoint::new(ctx);
1026        let (resource, _answer) = endpoint
1027            .accept_offer(offer, StreamKey::new("live", "cam"), transport)
1028            .unwrap();
1029        let pump = tokio::spawn(resource.pump());
1030
1031        // Base layer `q` → requested key; layer `h` → `cam~h`.
1032        let base = wait_for_stream(&engine, &StreamKey::new("live", "cam")).await;
1033        let high = wait_for_stream(&engine, &StreamKey::new("live", "cam~h")).await;
1034        assert!(base, "base simulcast layer published to the requested key");
1035        assert!(high, "second simulcast layer published to a per-rid key");
1036
1037        pump.abort();
1038    }
1039
1040    async fn wait_for_stream(engine: &Arc<crate::Engine>, key: &StreamKey) -> bool {
1041        for _ in 0..200 {
1042            if engine.get_stream(key).is_ok() {
1043                return true;
1044            }
1045            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1046        }
1047        false
1048    }
1049
1050    #[tokio::test]
1051    async fn accept_offer_builds_answer_with_transport_credentials() {
1052        let engine = crate::Engine::builder()
1053            .application(crate::AppSpec::new("live"))
1054            .build();
1055        let endpoint = WhipEndpoint::new(IngestContext::new(engine));
1056        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1057        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";
1058        let (_res, answer) = endpoint
1059            .accept_offer(offer, StreamKey::new("live", "web"), transport)
1060            .unwrap();
1061        assert!(answer.contains("a=ice-ufrag:ufrag"));
1062        assert!(answer.contains("a=fingerprint:sha-256 AA:BB"));
1063        assert!(answer.contains("a=setup:passive"));
1064    }
1065
1066    #[tokio::test]
1067    async fn pump_publishes_idr_then_releases_slot() {
1068        let engine = crate::Engine::builder()
1069            .application(crate::AppSpec::new("live").gop_cache(4))
1070            .build();
1071        let key = StreamKey::new("live", "web");
1072        let ctx = IngestContext::new(engine.clone());
1073
1074        let mut q = std::collections::VecDeque::new();
1075        q.push_back(rtp_packet(1, 0, true, &[0x65, 0x11])); // single IDR, marker
1076        let transport = Arc::new(FakeTransport::with_packets(q));
1077
1078        let resource = WhipResource {
1079            ctx,
1080            key: key.clone(),
1081            transport,
1082            video_pt: 96,
1083            audio_pt: None,
1084            rid_ext_id: None,
1085            simulcast_rids: Vec::new(),
1086        };
1087        resource.pump().await.unwrap();
1088
1089        // A complete keyframe was published, so no PLI was needed; the publish
1090        // slot is released once the transport drained.
1091        assert!(engine.get_stream(&key).is_err());
1092    }
1093
1094    #[tokio::test]
1095    async fn pump_requests_keyframe_on_a_depacketize_gap() {
1096        let engine = crate::Engine::builder()
1097            .application(crate::AppSpec::new("live").gop_cache(4))
1098            .build();
1099        let ctx = IngestContext::new(engine);
1100
1101        // A mid FU-A fragment with no start bit forces an OutOfOrder error → PLI.
1102        let mut q = std::collections::VecDeque::new();
1103        q.push_back(rtp_packet(1, 0, false, &[0x7C, 0x05, 0x11])); // FU-A, S=0
1104        let transport = Arc::new(FakeTransport::with_packets(q));
1105
1106        let resource = WhipResource {
1107            ctx,
1108            key: StreamKey::new("live", "web2"),
1109            transport: transport.clone(),
1110            video_pt: 96,
1111            audio_pt: None,
1112            rid_ext_id: None,
1113            simulcast_rids: Vec::new(),
1114        };
1115        resource.pump().await.unwrap();
1116        assert!(
1117            !transport.rtcp.lock().await.is_empty(),
1118            "a PLI was sent after the depacketize gap"
1119        );
1120    }
1121
1122    /// WHIP audio: an RTP packet on the negotiated Opus PT is published as an
1123    /// Opus audio frame (one packet per frame, 48 kHz → ms PTS), routed away from
1124    /// the H.264 depacketizer.
1125    #[tokio::test]
1126    async fn pump_routes_opus_audio_onto_the_bus() {
1127        let engine = crate::Engine::builder()
1128            .application(crate::AppSpec::new("live").gop_cache(8))
1129            .build();
1130        let key = StreamKey::new("live", "av");
1131        let ctx = IngestContext::new(engine.clone());
1132
1133        // Subscribe before pumping so we observe the published audio frame.
1134        let handle = engine.get_stream(&key);
1135        assert!(handle.is_err(), "stream not live until pump opens publish");
1136
1137        let mut q = std::collections::VecDeque::new();
1138        // PT 111 (Opus), ts 4800 → 100 ms; payload is an opaque Opus packet.
1139        q.push_back(rtp_packet_pt(111, 7, 4800, true, &[0xAA, 0xBB, 0xCC]));
1140        let transport = Arc::new(FakeTransport::with_packets(q));
1141
1142        let resource = WhipResource {
1143            ctx,
1144            key: key.clone(),
1145            transport,
1146            video_pt: 96,
1147            audio_pt: Some(111),
1148            rid_ext_id: None,
1149            simulcast_rids: Vec::new(),
1150        };
1151        // Capture frames via a parallel subscription opened once publishing starts.
1152        let pump = tokio::spawn(async move { resource.pump().await });
1153        // Give the pump a moment to open the publish + emit, then drain.
1154        let _ = pump.await.unwrap();
1155        // The stream closed cleanly after the single packet (no panic, no PLI:
1156        // audio never drives keyframe requests).
1157        assert!(engine.get_stream(&key).is_err());
1158    }
1159
1160    #[tokio::test]
1161    async fn whep_egress_packetizes_published_frames_as_rtp() {
1162        use crate::FrameFlags;
1163        let engine = crate::Engine::builder()
1164            .application(crate::AppSpec::new("live").gop_cache(8))
1165            .build();
1166        let key = StreamKey::new("live", "show");
1167
1168        // Publish a config + keyframe into the stream via an ingest session.
1169        let ctx = IngestContext::new(engine.clone());
1170        let session = ctx.open_publish(key.clone()).await.unwrap();
1171        let mut cfg = MediaFrame::new_video(
1172            0,
1173            0,
1174            bytes::Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42]),
1175            CodecId::H264,
1176            false,
1177        );
1178        cfg.flags |= FrameFlags::CONFIG;
1179        session.publish_frame(cfg).unwrap();
1180        session
1181            .publish_frame(MediaFrame::new_video(
1182                10,
1183                10,
1184                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x99]),
1185                CodecId::H264,
1186                true,
1187            ))
1188            .unwrap();
1189
1190        // A WHEP viewer subscribes and pumps; the bus closes when we finish().
1191        let whep = WhepEndpoint::new(engine.clone());
1192        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1193        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1194        let (resource, answer) = whep
1195            .accept_offer(offer, key.clone(), transport.clone())
1196            .unwrap();
1197        assert!(answer.contains("a=sendonly"), "WHEP answer is sendonly");
1198
1199        let pump = tokio::spawn(resource.pump());
1200        // Let the instant-start replay (config + keyframe) flush, then end the stream.
1201        for _ in 0..32 {
1202            if !transport.sent_rtp.lock().await.is_empty() {
1203                break;
1204            }
1205            tokio::task::yield_now().await;
1206        }
1207        session.finish().await.unwrap();
1208        let _ = pump.await.unwrap();
1209
1210        let sent = transport.sent_rtp.lock().await;
1211        assert!(!sent.is_empty(), "egress sent RTP packets");
1212        // The packets parse as RTP with our payload type.
1213        let h = RtpHeader::parse(&sent[0]).unwrap();
1214        assert_eq!(h.payload_type, 96);
1215    }
1216
1217    /// WHEP viewer feedback: a PLI arriving over `recv_rtcp` makes the egress
1218    /// re-send the cached config + most recent keyframe for fast recovery, so the
1219    /// keyframe NAL is transmitted again after the initial instant-start replay.
1220    #[tokio::test]
1221    async fn whep_egress_resends_keyframe_on_viewer_pli() {
1222        use crate::FrameFlags;
1223        let engine = crate::Engine::builder()
1224            .application(crate::AppSpec::new("live").gop_cache(8))
1225            .build();
1226        let key = StreamKey::new("live", "fb");
1227
1228        let ctx = IngestContext::new(engine.clone());
1229        let session = ctx.open_publish(key.clone()).await.unwrap();
1230        let mut cfg = MediaFrame::new_video(
1231            0,
1232            0,
1233            bytes::Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42]),
1234            CodecId::H264,
1235            false,
1236        );
1237        cfg.flags |= FrameFlags::CONFIG;
1238        session.publish_frame(cfg).unwrap();
1239        session
1240            .publish_frame(MediaFrame::new_video(
1241                10,
1242                10,
1243                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x99]),
1244                CodecId::H264,
1245                true,
1246            ))
1247            .unwrap();
1248
1249        // The viewer's feedback script: one PLI, delivered once the loop starts.
1250        let mut script = std::collections::VecDeque::new();
1251        script.push_back(rtcp::build_pli(0, 0));
1252        let transport = Arc::new(FakeTransport::with_inbound_rtcp(script));
1253
1254        let whep = WhepEndpoint::new(engine.clone());
1255        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1256        let (resource, _) = whep
1257            .accept_offer(offer, key.clone(), transport.clone())
1258            .unwrap();
1259        let pump = tokio::spawn(resource.pump());
1260
1261        // Wait until the keyframe NAL (0x65) has been sent twice: once on the
1262        // instant-start replay, once again from the PLI-triggered refresh.
1263        let count_keyframes = |pkts: &[Vec<u8>]| {
1264            pkts.iter()
1265                .filter(|p| {
1266                    RtpHeader::parse(p)
1267                        .map(|h| p[h.payload_offset..].windows(1).any(|w| w[0] == 0x65))
1268                        .unwrap_or(false)
1269                })
1270                .count()
1271        };
1272        let mut refreshed = false;
1273        for _ in 0..500 {
1274            if count_keyframes(&transport.sent_rtp.lock().await) >= 2 {
1275                refreshed = true;
1276                break;
1277            }
1278            // A real (short) sleep lets the spawned pump task make progress and
1279            // its timer-driven branches advance, instead of racing a yield budget.
1280            tokio::time::sleep(std::time::Duration::from_millis(2)).await;
1281        }
1282        session.finish().await.unwrap();
1283        let _ = pump.await.unwrap();
1284        assert!(refreshed, "viewer PLI re-sent the keyframe");
1285    }
1286
1287    #[test]
1288    fn select_layer_picks_fit_with_hysteresis() {
1289        let k = |s: &str| StreamKey::new("live", s);
1290        // Three layers: low 300k, mid 800k, high 2.5M.
1291        let layers = vec![
1292            (k("show"), 300_000u64),
1293            (k("show~h"), 800_000),
1294            (k("show~f"), 2_500_000),
1295        ];
1296
1297        // Plenty of bandwidth, currently on low → up-switch to high (>25% headroom
1298        // over 2.5M needs >= 3.125M; give 4M).
1299        assert_eq!(
1300            select_layer(&layers, Some(4_000_000), &k("show")),
1301            k("show~f")
1302        );
1303
1304        // Marginal bandwidth just above mid's bitrate but below the 25% headroom
1305        // for up-switching from low → no up-switch (stays low).
1306        assert_eq!(select_layer(&layers, Some(820_000), &k("show")), k("show"));
1307
1308        // On high, bandwidth collapses below 95% of high → down-switch to the best
1309        // fit (mid at 800k fits 900k).
1310        assert_eq!(
1311            select_layer(&layers, Some(900_000), &k("show~f")),
1312            k("show~h")
1313        );
1314
1315        // No estimate yet → stay on the current layer.
1316        assert_eq!(select_layer(&layers, None, &k("show~h")), k("show~h"));
1317
1318        // Nothing fits (estimate below the floor) → the floor layer.
1319        assert_eq!(
1320            select_layer(&layers, Some(100_000), &k("show~f")),
1321            k("show")
1322        );
1323
1324        // Single layer (no simulcast) → always itself.
1325        let one = vec![(k("solo"), 0u64)];
1326        assert_eq!(select_layer(&one, Some(5_000_000), &k("solo")), k("solo"));
1327    }
1328
1329    /// WHEP layer discovery: the base stream plus its `~rid` simulcast siblings
1330    /// are enumerated for adaptive-bitrate selection; unrelated streams are not.
1331    #[tokio::test]
1332    async fn discover_layers_lists_base_and_simulcast_siblings() {
1333        let engine = crate::Engine::builder()
1334            .application(crate::AppSpec::new("live").gop_cache(4))
1335            .build();
1336        let ctx = IngestContext::new(engine.clone());
1337        // Publish a base layer, two simulcast siblings, and an unrelated stream.
1338        for id in ["show", "show~h", "show~f", "other"] {
1339            let s = ctx.open_publish(StreamKey::new("live", id)).await.unwrap();
1340            s.publish_frame(MediaFrame::new_video(
1341                0,
1342                0,
1343                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65]),
1344                CodecId::H264,
1345                true,
1346            ))
1347            .unwrap();
1348            std::mem::forget(s); // keep the streams live for the listing
1349        }
1350
1351        let whep = WhepEndpoint::new(engine.clone());
1352        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1353        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
1354        let (resource, _) = whep
1355            .accept_offer(offer, StreamKey::new("live", "show"), transport)
1356            .unwrap();
1357
1358        let mut ids: Vec<String> = resource
1359            .discover_layers()
1360            .into_iter()
1361            .map(|(k, _)| k.stream_id.as_str().to_string())
1362            .collect();
1363        ids.sort();
1364        assert_eq!(ids, vec!["show", "show~f", "show~h"]);
1365    }
1366
1367    /// The upstream keyframe backchannel: a viewer PLI on the WHEP egress calls
1368    /// `StreamHandle::request_keyframe`, which the publisher's ingest loop awaits
1369    /// via `keyframe_requested` — so a browser PLI reaches the WHIP publisher.
1370    #[tokio::test]
1371    async fn request_keyframe_signals_the_publishers_handle() {
1372        let engine = crate::Engine::builder()
1373            .application(crate::AppSpec::new("live").gop_cache(8))
1374            .build();
1375        let key = StreamKey::new("live", "kf");
1376
1377        let ctx = IngestContext::new(engine.clone());
1378        let session = ctx.open_publish(key.clone()).await.unwrap();
1379        // The publisher's loop awaits a keyframe request on its handle.
1380        let pub_handle = session.handle().clone();
1381        let waiter = tokio::spawn(async move {
1382            tokio::time::timeout(
1383                std::time::Duration::from_secs(2),
1384                pub_handle.keyframe_requested(),
1385            )
1386            .await
1387        });
1388
1389        // A playback consumer resolves the *same* stream and requests a keyframe.
1390        let view_handle = engine.get_stream(&key).unwrap();
1391        // Give the waiter a moment to park on `notified()` before signaling.
1392        tokio::task::yield_now().await;
1393        view_handle.request_keyframe();
1394
1395        assert!(waiter.await.unwrap().is_ok(), "publisher saw the request");
1396    }
1397
1398    /// WHEP audio: when the viewer's offer carries an Opus audio line, published
1399    /// Opus audio frames are RTP-packetized on the audio payload type and sent.
1400    #[tokio::test]
1401    async fn whep_egress_packetizes_opus_audio() {
1402        let engine = crate::Engine::builder()
1403            .application(crate::AppSpec::new("live").gop_cache(8))
1404            .build();
1405        let key = StreamKey::new("live", "aud");
1406
1407        let ctx = IngestContext::new(engine.clone());
1408        let session = ctx.open_publish(key.clone()).await.unwrap();
1409        // A keyframe (so the GOP replay has video) plus an Opus audio frame.
1410        session
1411            .publish_frame(MediaFrame::new_video(
1412                0,
1413                0,
1414                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]),
1415                CodecId::H264,
1416                true,
1417            ))
1418            .unwrap();
1419        session
1420            .publish_frame(MediaFrame::new_audio(
1421                20,
1422                bytes::Bytes::from_static(&[0xDE, 0xAD, 0xBE, 0xEF]),
1423                CodecId::Opus,
1424            ))
1425            .unwrap();
1426
1427        let whep = WhepEndpoint::new(engine.clone());
1428        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1429        // Offer with both video and Opus audio (PT 111).
1430        let offer = "v=0\r\n\
1431m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n\
1432m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=rtpmap:111 opus/48000/2\r\n";
1433        let (resource, _answer) = whep
1434            .accept_offer(offer, key.clone(), transport.clone())
1435            .unwrap();
1436
1437        let pump = tokio::spawn(resource.pump());
1438        for _ in 0..64 {
1439            if transport
1440                .sent_rtp
1441                .lock()
1442                .await
1443                .iter()
1444                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111))
1445            {
1446                break;
1447            }
1448            tokio::task::yield_now().await;
1449        }
1450        session.finish().await.unwrap();
1451        let _ = pump.await.unwrap();
1452
1453        let sent = transport.sent_rtp.lock().await;
1454        assert!(
1455            sent.iter()
1456                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111)),
1457            "egress sent an Opus audio RTP packet on PT 111"
1458        );
1459    }
1460
1461    #[tokio::test]
1462    async fn whep_egress_packetizes_vp9_frames() {
1463        let engine = crate::Engine::builder()
1464            .application(crate::AppSpec::new("live").gop_cache(8))
1465            .build();
1466        let key = StreamKey::new("live", "vp9");
1467
1468        // Publish a VP9 keyframe (no config AU — codec is inferred from the frame).
1469        let ctx = IngestContext::new(engine.clone());
1470        let session = ctx.open_publish(key.clone()).await.unwrap();
1471        let frame_data = bytes::Bytes::from_static(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
1472        session
1473            .publish_frame(MediaFrame::new_video(
1474                0,
1475                0,
1476                frame_data.clone(),
1477                CodecId::VP9,
1478                true,
1479            ))
1480            .unwrap();
1481
1482        let whep = WhepEndpoint::new(engine.clone());
1483        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
1484        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 VP9/90000\r\n";
1485        let (resource, _answer) = whep
1486            .accept_offer(offer, key.clone(), transport.clone())
1487            .unwrap();
1488
1489        let pump = tokio::spawn(resource.pump());
1490        for _ in 0..32 {
1491            if !transport.sent_rtp.lock().await.is_empty() {
1492                break;
1493            }
1494            tokio::task::yield_now().await;
1495        }
1496        session.finish().await.unwrap();
1497        let _ = pump.await.unwrap();
1498
1499        // The egress RTP round-trips back to the original VP9 frame.
1500        let sent = transport.sent_rtp.lock().await;
1501        assert!(!sent.is_empty(), "VP9 egress sent RTP packets");
1502        let mut depack = crate::protocol::rtp::Vp9Depacketizer::new();
1503        let mut out = None;
1504        for p in sent.iter() {
1505            let h = RtpHeader::parse(p).unwrap();
1506            if let Some(f) = depack
1507                .push(&p[h.payload_offset..], h.marker, h.timestamp)
1508                .unwrap()
1509            {
1510                out = Some(f);
1511            }
1512        }
1513        let out = out.expect("VP9 frame completed");
1514        assert_eq!(&out.data[..], &frame_data[..], "VP9 frame reconstructed");
1515        assert!(out.keyframe);
1516    }
1517}