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 rtcp;
42pub mod sdp;
43
44pub use ice::{parse_trickle, IceCandidate};
45pub use sdp::{MediaDirection, SdpAnswerParams, SdpOffer};
46
47use crate::bus::PlaybackRegistry;
48use crate::inbound::{IngestContext, PublishSession};
49#[cfg(feature = "codec-av1")]
50use crate::protocol::rtp::Av1Packetizer;
51use crate::protocol::rtp::{
52    H264Depacketizer, OpusPacketizer, RtpHeader, RtpPacketizer, Vp9Packetizer,
53};
54use crate::{CodecId, MediaFrame, Result, StreamKey};
55use async_trait::async_trait;
56use std::sync::Arc;
57
58/// The host-supplied DTLS-SRTP transport for one peer connection.
59///
60/// Implement this over a vetted WebRTC crypto stack. The kernel calls it to pull
61/// decrypted RTP and to push RTCP feedback; it never sees keys or handshakes.
62#[async_trait]
63pub trait DtlsSrtpTransport: Send + Sync {
64    /// The DTLS certificate fingerprint (`sha-256 AA:BB:…`) to advertise in the
65    /// SDP answer's `a=fingerprint` line.
66    fn fingerprint(&self) -> String;
67
68    /// The ICE ufrag/pwd pair to advertise in the SDP answer.
69    ///
70    /// Per RFC 5245 these have length limits browsers enforce: the **ufrag** must
71    /// be 4–256 characters and the **pwd** 22–256 characters. A pwd shorter than
72    /// 22 chars makes `setRemoteDescription` reject the answer with
73    /// `Invalid ICE parameters`.
74    fn ice_credentials(&self) -> (String, String);
75
76    /// Receive the next decrypted RTP packet, or `None` when the peer closes.
77    /// Used by the WHIP **ingest** path; a send-only WHEP transport may leave the
78    /// default (returns `None` immediately).
79    async fn recv_rtp(&self) -> Option<Vec<u8>> {
80        None
81    }
82
83    /// Send an RTP packet to the peer (SRTP-encrypted by the transport). Used by
84    /// the WHEP **egress** path.
85    async fn send_rtp(&self, _packet: &[u8]) -> Result<()> {
86        Ok(())
87    }
88
89    /// Send an RTCP packet (e.g. a PLI/FIR built by [`rtcp`]) back to the peer.
90    async fn send_rtcp(&self, packet: &[u8]) -> Result<()>;
91
92    /// Add a remote ICE candidate trickled in after the initial answer (a WHIP/
93    /// WHEP `PATCH`; see [`ice`]). The `candidate` is the SDP attribute value
94    /// (everything after `a=candidate:`). The default is a no-op for transports
95    /// that gather all candidates up front (non-trickle).
96    async fn add_remote_candidate(&self, _candidate: &str) -> Result<()> {
97        Ok(())
98    }
99
100    /// Receive the next application **data-channel** message as `(label, bytes)`,
101    /// or `None` when no data channel is open / the peer closed it. The default
102    /// returns `None` (no data-channel support).
103    async fn recv_data(&self) -> Option<(String, Vec<u8>)> {
104        None
105    }
106
107    /// Send an application **data-channel** message on the channel named `label`.
108    /// The default is a no-op (no data-channel support).
109    async fn send_data(&self, _label: &str, _data: &[u8]) -> Result<()> {
110        Ok(())
111    }
112
113    /// Produce the SDP **answer** for the raw `offer_sdp` with the given media
114    /// `direction`.
115    ///
116    /// This is the seam's SDP hook: the default builds a minimal answer from the
117    /// transport's [`fingerprint`](Self::fingerprint) and
118    /// [`ice_credentials`](Self::ice_credentials) — correct for the kernel's
119    /// in-crate SDP. A transport that owns SDP generation itself (e.g. a str0m
120    /// backend) overrides this to parse the offer and return its own complete
121    /// answer, so the kernel never imposes its SDP shape on a real WebRTC stack.
122    fn answer(&self, offer_sdp: &str, direction: MediaDirection) -> String {
123        let Some(offer) = SdpOffer::parse(offer_sdp) else {
124            return String::new();
125        };
126        let (ice_ufrag, ice_pwd) = self.ice_credentials();
127        sdp::build_answer_directed(
128            &offer,
129            &SdpAnswerParams {
130                fingerprint: self.fingerprint(),
131                ice_ufrag,
132                ice_pwd,
133            },
134            direction,
135        )
136    }
137}
138
139/// A WHIP/WHEP signaling endpoint the host drives from its HTTP layer.
140///
141/// `POST` of an SDP offer → [`accept_offer`](Self::accept_offer) returns the SDP
142/// answer to write back with `201 Created`. The returned [`WhipResource`] is the
143/// handle the host stores (keyed by the `Location` URL) and later
144/// [`close`](WhipResource::close)s on `DELETE`.
145#[derive(Clone)]
146pub struct WhipEndpoint {
147    ctx: IngestContext,
148}
149
150impl WhipEndpoint {
151    /// Build an endpoint that publishes ingested media through `ctx`.
152    pub fn new(ctx: IngestContext) -> Self {
153        Self { ctx }
154    }
155
156    /// Handle a WHIP `POST`: validate the offer, mint the answer from the
157    /// transport's credentials, and return the resource handle plus the answer
158    /// SDP. The host then runs [`WhipResource::pump`] (typically `tokio::spawn`)
159    /// to move media for the connection's lifetime.
160    pub fn accept_offer(
161        &self,
162        offer_sdp: &str,
163        key: StreamKey,
164        transport: std::sync::Arc<dyn DtlsSrtpTransport>,
165    ) -> Result<(WhipResource, String)> {
166        // Validate the offer parses; the transport owns answer generation (see
167        // `DtlsSrtpTransport::answer`) and gets the raw SDP.
168        let offer = SdpOffer::parse(offer_sdp)
169            .ok_or_else(|| crate::StreamError::protocol("malformed SDP offer"))?;
170        // WHIP ingest: the publisher sends, we receive → recvonly answer.
171        let answer = transport.answer(offer_sdp, MediaDirection::RecvOnly);
172        let resource = WhipResource {
173            ctx: self.ctx.clone(),
174            key,
175            transport,
176            video_pt: offer.payload_type,
177            audio_pt: offer.audio_payload_type,
178            rid_ext_id: offer.rid_ext_id,
179            simulcast_rids: offer.simulcast_rids,
180        };
181        Ok((resource, answer))
182    }
183}
184
185/// An accepted WHIP connection: pumps decrypted RTP onto the bus until the peer
186/// or transport closes.
187pub struct WhipResource {
188    ctx: IngestContext,
189    key: StreamKey,
190    transport: std::sync::Arc<dyn DtlsSrtpTransport>,
191    /// Negotiated H.264 video payload type (RTP packets carrying it are
192    /// depacketized into access units).
193    video_pt: u8,
194    /// Negotiated Opus audio payload type, if the publisher offered audio — RTP
195    /// packets carrying it are published as Opus audio frames directly.
196    audio_pt: Option<u8>,
197    /// RTP header-extension id carrying the RID (simulcast layer label), if the
198    /// offer negotiated simulcast.
199    rid_ext_id: Option<u8>,
200    /// Simulcast layers offered (rid identifiers, declared order). The first is
201    /// the *base* layer published to the requested key; others go to a
202    /// `<stream>~<rid>` key so a subscriber can pick a layer.
203    simulcast_rids: Vec<String>,
204}
205
206impl WhipResource {
207    /// Drive the media plane until the transport yields `None` (peer gone).
208    ///
209    /// A simulcast publisher (multiple RID layers negotiated) is demultiplexed
210    /// per layer onto distinct streams; otherwise a single stream is routed by
211    /// payload type — H.264 depacketized into access units, Opus audio published
212    /// frame-for-packet.
213    pub async fn pump(self) -> Result<()> {
214        match self.rid_ext_id {
215            Some(ext) if self.simulcast_rids.len() > 1 => self.pump_simulcast(ext).await,
216            _ => self.pump_single().await,
217        }
218    }
219
220    /// The non-simulcast path: one publish session routed by payload type.
221    async fn pump_single(self) -> Result<()> {
222        let session: PublishSession = self.ctx.open_publish(self.key.clone()).await?;
223        let mut depack = H264Depacketizer::new();
224        let mut needs_keyframe = true;
225
226        while let Some(pkt) = self.transport.recv_rtp().await {
227            let Some(header) = RtpHeader::parse(&pkt) else {
228                continue;
229            };
230            let payload = &pkt[header.payload_offset..];
231
232            // Audio: one Opus packet per RTP payload (no depacketization). The
233            // Opus RTP clock is 48 kHz, so PTS(ms) = timestamp / 48.
234            if self.audio_pt == Some(header.payload_type) {
235                if !payload.is_empty() {
236                    let pts = (header.timestamp / 48) as i64;
237                    let data = bytes::Bytes::copy_from_slice(payload);
238                    let frame = MediaFrame::new_audio(pts, data, CodecId::Opus);
239                    let _ = session.publish_frame(frame)?;
240                }
241                continue;
242            }
243
244            // Video (default): everything else is treated as the H.264 stream.
245            let _ = self.video_pt; // negotiated PT (routing is by elimination here)
246            match depack.push(payload, header.marker, header.timestamp, header.sequence) {
247                Ok(Some(au)) => {
248                    needs_keyframe = false;
249                    let pts = (au.timestamp / 90) as i64;
250                    let frame =
251                        MediaFrame::new_video(pts, pts, au.data, CodecId::H264, au.keyframe);
252                    let _ = session.publish_frame(frame)?;
253                }
254                Ok(None) => {}
255                Err(_) => {
256                    // Loss/gap: ask the sender for a fresh IDR via RTCP PLI.
257                    needs_keyframe = true;
258                }
259            }
260            if needs_keyframe {
261                let pli = rtcp::build_pli(0, header.ssrc);
262                let _ = self.transport.send_rtcp(&pli).await;
263            }
264        }
265
266        session.finish().await
267    }
268
269    /// The simulcast path: each RID layer is depacketized independently and
270    /// published to its own stream — the base (first-declared) layer to the
271    /// requested key, the rest to `<stream>~<rid>` so a viewer can select one.
272    /// This is the SFU's RID-routing core; layer *selection*/forwarding policy
273    /// then lives in the consumer that subscribes to these per-layer streams.
274    async fn pump_simulcast(self, rid_ext: u8) -> Result<()> {
275        use std::collections::HashMap;
276        struct Layer {
277            session: PublishSession,
278            depack: H264Depacketizer,
279            needs_keyframe: bool,
280        }
281        let base = self.simulcast_rids[0].clone();
282        let mut layers: HashMap<String, Layer> = HashMap::new();
283
284        while let Some(pkt) = self.transport.recv_rtp().await {
285            let Some(header) = RtpHeader::parse(&pkt) else {
286                continue;
287            };
288            // Label the packet by its RID extension; packets without one fall to
289            // the base layer (some senders omit the rid on the base encoding).
290            let rid = crate::protocol::rtp::rtp_extension_value(&pkt, rid_ext)
291                .and_then(|b| std::str::from_utf8(b).ok())
292                .map(str::to_owned)
293                .unwrap_or_else(|| base.clone());
294            if !self.simulcast_rids.contains(&rid) {
295                continue; // unknown layer label
296            }
297
298            if !layers.contains_key(&rid) {
299                let key = self.layer_key(&rid, &base);
300                let session = self.ctx.open_publish(key).await?;
301                layers.insert(
302                    rid.clone(),
303                    Layer {
304                        session,
305                        depack: H264Depacketizer::new(),
306                        needs_keyframe: true,
307                    },
308                );
309            }
310            let layer = layers.get_mut(&rid).unwrap();
311            let payload = &pkt[header.payload_offset..];
312            match layer
313                .depack
314                .push(payload, header.marker, header.timestamp, header.sequence)
315            {
316                Ok(Some(au)) => {
317                    layer.needs_keyframe = false;
318                    let pts = (au.timestamp / 90) as i64;
319                    let frame =
320                        MediaFrame::new_video(pts, pts, au.data, CodecId::H264, au.keyframe);
321                    let _ = layer.session.publish_frame(frame)?;
322                }
323                Ok(None) => {}
324                Err(_) => layer.needs_keyframe = true,
325            }
326            if layer.needs_keyframe {
327                let pli = rtcp::build_pli(0, header.ssrc);
328                let _ = self.transport.send_rtcp(&pli).await;
329            }
330        }
331
332        for (_, layer) in layers {
333            layer.session.finish().await?;
334        }
335        Ok(())
336    }
337
338    /// The stream key a simulcast layer publishes to: the requested key for the
339    /// base layer, `<stream>~<rid>` for the others.
340    fn layer_key(&self, rid: &str, base: &str) -> StreamKey {
341        if rid == base {
342            self.key.clone()
343        } else {
344            StreamKey::new(
345                self.key.app.as_str(),
346                format!("{}~{}", self.key.stream_id.as_str(), rid),
347            )
348        }
349    }
350
351    /// Tear the resource down on a WHIP `DELETE` without pumping media.
352    pub async fn close(self) -> Result<()> {
353        Ok(())
354    }
355}
356
357/// A WHEP (egress) signaling endpoint — the playback counterpart to
358/// [`WhipEndpoint`].
359///
360/// A viewer `POST`s an SDP offer; [`accept_offer`](Self::accept_offer) returns a
361/// `sendonly` answer and a [`WhepResource`]. The host then runs
362/// [`WhepResource::pump`], which subscribes to the requested live stream,
363/// packetizes each H.264 access unit into RTP, and sends it over the peer's
364/// [`DtlsSrtpTransport`] — sub-second WebRTC playback.
365#[derive(Clone)]
366pub struct WhepEndpoint {
367    playback: Arc<dyn PlaybackRegistry>,
368}
369
370impl WhepEndpoint {
371    /// Build an endpoint that serves media from `playback` (e.g. an `Arc<Engine>`).
372    pub fn new(playback: Arc<dyn PlaybackRegistry>) -> Self {
373        Self { playback }
374    }
375
376    /// Handle a WHEP `POST`: validate the offer and mint a `sendonly` answer.
377    /// Returns the resource handle (to `pump`) and the answer SDP.
378    pub fn accept_offer(
379        &self,
380        offer_sdp: &str,
381        key: StreamKey,
382        transport: Arc<dyn DtlsSrtpTransport>,
383    ) -> Result<(WhepResource, String)> {
384        let offer = SdpOffer::parse(offer_sdp)
385            .ok_or_else(|| crate::StreamError::protocol("malformed SDP offer"))?;
386        // WHEP egress: we send to the viewer → sendonly answer (transport-owned).
387        let answer = transport.answer(offer_sdp, MediaDirection::SendOnly);
388        let resource = WhepResource {
389            playback: Arc::clone(&self.playback),
390            key,
391            transport,
392            payload_type: offer.payload_type,
393            audio_payload_type: offer.audio_payload_type,
394            warned_unsupported: std::sync::atomic::AtomicBool::new(false),
395        };
396        Ok((resource, answer))
397    }
398}
399
400/// An accepted WHEP connection: streams one live stream out to the viewer as RTP
401/// until the stream ends or the peer disconnects.
402pub struct WhepResource {
403    playback: Arc<dyn PlaybackRegistry>,
404    key: StreamKey,
405    transport: Arc<dyn DtlsSrtpTransport>,
406    payload_type: u8,
407    /// Negotiated Opus audio payload type, when the viewer's offer carried audio.
408    /// `None` disables audio egress (video-only viewer or non-Opus source).
409    audio_payload_type: Option<u8>,
410    /// Set once we have warned about an unsupported egress video codec, so the
411    /// log line fires a single time per connection instead of per frame.
412    warned_unsupported: std::sync::atomic::AtomicBool,
413}
414
415/// The RTP payload format chosen for a WHEP egress connection, selected from the
416/// stream's video codec. Each variant only packetizes frames of its own codec;
417/// a mismatched frame yields `None` (skipped, observably) from
418/// [`packetize`](EgressPacketizer::packetize).
419enum EgressPacketizer {
420    /// NAL codecs — H.264 (RFC 6184) or H.265 (RFC 7798).
421    Nal { p: RtpPacketizer, codec: CodecId },
422    /// VP9 (draft-ietf-payload-vp9).
423    Vp9(Vp9Packetizer),
424    /// AV1 (AOMedia RTP).
425    #[cfg(feature = "codec-av1")]
426    Av1(Av1Packetizer),
427}
428
429impl EgressPacketizer {
430    /// Build the packetizer for `codec`. Codecs without an RTP payload format in
431    /// this build fall back to an H.264 NAL packetizer, so their frames are
432    /// skipped observably rather than mis-framed.
433    fn for_codec(payload_type: u8, ssrc: u32, mtu: usize, codec: CodecId) -> Self {
434        match codec {
435            CodecId::H265 => EgressPacketizer::Nal {
436                p: RtpPacketizer::new_h265(payload_type, ssrc, mtu),
437                codec: CodecId::H265,
438            },
439            CodecId::VP9 => EgressPacketizer::Vp9(Vp9Packetizer::new(payload_type, ssrc, mtu)),
440            #[cfg(feature = "codec-av1")]
441            CodecId::AV1 => EgressPacketizer::Av1(Av1Packetizer::new(payload_type, ssrc, mtu)),
442            _ => EgressPacketizer::Nal {
443                p: RtpPacketizer::new(payload_type, ssrc, mtu),
444                codec: CodecId::H264,
445            },
446        }
447    }
448
449    /// Packetize one video frame at its 90 kHz timestamp into the recycled
450    /// `out` buffer, returning `true` if the frame's codec matched this
451    /// packetizer (and `false`, leaving `out` empty, when it did not).
452    fn packetize_into(&mut self, frame: &MediaFrame, out: &mut Vec<Vec<u8>>) -> bool {
453        // Clamp negative PTS to 0 before the u64 cast (otherwise it wraps to a
454        // huge timestamp and desynchronizes the receiver's clock).
455        let ts = (frame.pts.max(0) as u64).wrapping_mul(90) as u32; // ms → 90 kHz
456        match self {
457            EgressPacketizer::Nal { p, codec } if frame.codec == *codec => {
458                p.packetize_into(&frame.data, ts, out);
459                true
460            }
461            EgressPacketizer::Vp9(p) if frame.codec == CodecId::VP9 => {
462                p.packetize_into(&frame.data, ts, frame.is_keyframe(), out);
463                true
464            }
465            #[cfg(feature = "codec-av1")]
466            EgressPacketizer::Av1(p) if frame.codec == CodecId::AV1 => {
467                p.packetize_into(&frame.data, ts, out);
468                true
469            }
470            _ => false,
471        }
472    }
473}
474
475impl WhepResource {
476    /// Drive egress: subscribe to the stream, replay the cached config + GOP for
477    /// an instant start, then packetize and send every published video frame.
478    /// Returns when the stream closes or the subscription lags out.
479    ///
480    /// The RTP payload format is selected from the stream's video codec: H.264
481    /// (RFC 6184), H.265 (RFC 7798), VP9, and AV1 (with `codec-av1`) are
482    /// packetized; other video codecs are skipped with a single warning per
483    /// connection, and audio is skipped.
484    pub async fn pump(self) -> Result<()> {
485        let handle = self.playback.get_stream(&self.key)?;
486        // SSRC derived from the key so retries are stable; real deployments may
487        // randomize per PeerConnection.
488        let ssrc = 0x5745_4850; // "WEHP"
489        let mut sub = handle.subscribe_resilient();
490
491        // Instant start: send the cached config frame + GOP before live frames.
492        let (vcfg, _) = handle.cached_configs();
493        let replay = handle.replay_buffer();
494        // Release the handle once setup is done. With the registry-owned sender
495        // (`StreamHandle::close`) the channel closes on publish-end regardless,
496        // but dropping eagerly keeps this pump from pinning per-stream state.
497        drop(handle);
498
499        // Pick the payload format from the stream's video codec (config frame
500        // first, else the first replayed video frame; defaulting to H.264).
501        let video_codec = vcfg
502            .as_ref()
503            .map(|c| c.codec)
504            .or_else(|| replay.iter().find(|f| f.is_video()).map(|f| f.codec))
505            .unwrap_or(CodecId::H264);
506        let mut packetizer =
507            EgressPacketizer::for_codec(self.payload_type, ssrc, 1200, video_codec);
508        // Opus audio packetizer on a distinct SSRC, when the viewer offered audio.
509        // Only Opus frames are sent (an AAC source's audio is skipped — a browser
510        // can't decode AAC over this Opus payload type).
511        let mut audio = self
512            .audio_payload_type
513            .map(|pt| OpusPacketizer::new(pt, 0x5745_4151)); // "WEAQ"
514
515        // Reused across frames so steady-state egress allocates no packet buffers.
516        let mut pkts: Vec<Vec<u8>> = Vec::new();
517
518        if let Some(cfg) = vcfg {
519            self.send_frame(&cfg, &mut packetizer, &mut audio, &mut pkts)
520                .await?;
521        }
522        for frame in replay {
523            self.send_frame(&frame, &mut packetizer, &mut audio, &mut pkts)
524                .await?;
525        }
526
527        while let Some(frame) = sub.recv().await {
528            self.send_frame(&frame, &mut packetizer, &mut audio, &mut pkts)
529                .await?;
530        }
531        Ok(())
532    }
533
534    /// Packetize one frame and send each RTP packet over the transport.
535    ///
536    /// Video is packetized by the connection's [`EgressPacketizer`]; Opus audio
537    /// (when the viewer negotiated it) by the [`OpusPacketizer`]. A video frame
538    /// whose codec the packetizer can't handle is skipped with a single warning
539    /// per connection — an *observable* skip, never a silent drop. Non-Opus audio
540    /// is skipped silently (a different audio codec is expected on many sources).
541    async fn send_frame(
542        &self,
543        frame: &MediaFrame,
544        packetizer: &mut EgressPacketizer,
545        audio: &mut Option<OpusPacketizer>,
546        pkts: &mut Vec<Vec<u8>>,
547    ) -> Result<()> {
548        if frame.is_audio() {
549            if let Some(ap) = audio.as_mut() {
550                if frame.codec == CodecId::Opus {
551                    let ts = (frame.pts.max(0) as u64).wrapping_mul(48) as u32; // ms → 48 kHz
552                    ap.packetize_into(&frame.data, ts, pkts);
553                    for packet in pkts.iter() {
554                        self.transport.send_rtp(packet).await?;
555                    }
556                }
557            }
558            return Ok(());
559        }
560        if !frame.is_video() {
561            return Ok(());
562        }
563        if packetizer.packetize_into(frame, pkts) {
564            for packet in pkts.iter() {
565                self.transport.send_rtp(packet).await?;
566            }
567        } else {
568            use std::sync::atomic::Ordering;
569            if !self.warned_unsupported.swap(true, Ordering::Relaxed) {
570                tracing::warn!(
571                    stream = %self.key,
572                    codec = ?frame.codec,
573                    "WHEP egress: unsupported video codec; frames skipped",
574                );
575            }
576        }
577        Ok(())
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::bus::PlaybackRegistry;
585    use std::sync::Arc;
586    use tokio::sync::Mutex;
587
588    /// A fake transport that replays a fixed RTP script and records what is sent.
589    struct FakeTransport {
590        packets: Mutex<std::collections::VecDeque<Vec<u8>>>,
591        rtcp: Mutex<Vec<Vec<u8>>>,
592        sent_rtp: Mutex<Vec<Vec<u8>>>,
593        /// When the script drains, stay open (block) instead of yielding `None`,
594        /// so a spawned `pump` keeps its publish sessions alive for inspection.
595        keep_open: bool,
596    }
597
598    impl FakeTransport {
599        fn with_packets(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
600            Self {
601                packets: Mutex::new(packets),
602                rtcp: Mutex::new(Vec::new()),
603                sent_rtp: Mutex::new(Vec::new()),
604                keep_open: false,
605            }
606        }
607
608        fn with_packets_keep_open(packets: std::collections::VecDeque<Vec<u8>>) -> Self {
609            Self {
610                keep_open: true,
611                ..Self::with_packets(packets)
612            }
613        }
614    }
615
616    #[async_trait]
617    impl DtlsSrtpTransport for FakeTransport {
618        fn fingerprint(&self) -> String {
619            "sha-256 AA:BB".into()
620        }
621        fn ice_credentials(&self) -> (String, String) {
622            ("ufrag".into(), "pwd".into())
623        }
624        async fn recv_rtp(&self) -> Option<Vec<u8>> {
625            match self.packets.lock().await.pop_front() {
626                Some(p) => Some(p),
627                None if self.keep_open => std::future::pending().await,
628                None => None,
629            }
630        }
631        async fn send_rtp(&self, packet: &[u8]) -> Result<()> {
632            self.sent_rtp.lock().await.push(packet.to_vec());
633            Ok(())
634        }
635        async fn send_rtcp(&self, packet: &[u8]) -> Result<()> {
636            self.rtcp.lock().await.push(packet.to_vec());
637            Ok(())
638        }
639    }
640
641    fn rtp_packet(seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
642        rtp_packet_pt(96, seq, ts, marker, payload)
643    }
644
645    fn rtp_packet_pt(pt: u8, seq: u16, ts: u32, marker: bool, payload: &[u8]) -> Vec<u8> {
646        let mut p = vec![0x80, if marker { 0x80 | pt } else { pt & 0x7F }];
647        p.extend_from_slice(&seq.to_be_bytes());
648        p.extend_from_slice(&ts.to_be_bytes());
649        p.extend_from_slice(&[0, 0, 0, 7]);
650        p.extend_from_slice(payload);
651        p
652    }
653
654    /// An RTP packet tagged with a one-byte RID header extension `(ext_id, rid)`.
655    fn rtp_with_rid(ext_id: u8, rid: &str, seq: u16, marker: bool, payload: &[u8]) -> Vec<u8> {
656        let mut p = vec![0x90, if marker { 0x80 | 96 } else { 96 }]; // X bit + PT 96
657        p.extend_from_slice(&seq.to_be_bytes());
658        p.extend_from_slice(&0u32.to_be_bytes()); // ts
659        p.extend_from_slice(&[0, 0, 0, 7]); // ssrc
660        p.extend_from_slice(&0xBEDEu16.to_be_bytes()); // one-byte ext profile
661        let mut ext = vec![(ext_id << 4) | (rid.len() as u8 - 1)];
662        ext.extend_from_slice(rid.as_bytes());
663        while ext.len() % 4 != 0 {
664            ext.push(0);
665        }
666        p.extend_from_slice(&((ext.len() / 4) as u16).to_be_bytes());
667        p.extend_from_slice(&ext);
668        p.extend_from_slice(payload);
669        p
670    }
671
672    /// Simulcast WHIP: two RID layers (`q`, `h`) are demultiplexed onto distinct
673    /// streams — the base layer to the requested key, the other to `<stream>~h`.
674    #[tokio::test]
675    async fn pump_routes_simulcast_layers_to_per_layer_streams() {
676        let engine = crate::Engine::builder()
677            .application(crate::AppSpec::new("live").gop_cache(4))
678            .build();
679        let ctx = IngestContext::new(engine.clone());
680        let offer = "v=0\r\n\
681o=- 0 0 IN IP4 0.0.0.0\r\n\
682m=video 9 UDP/TLS/RTP/SAVPF 96\r\n\
683a=mid:0\r\n\
684a=sendonly\r\n\
685a=rtpmap:96 H264/90000\r\n\
686a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:rid\r\n\
687a=rid:q send\r\n\
688a=rid:h send\r\n\
689a=simulcast:send q;h\r\n";
690
691        // One IDR per layer, each tagged with its rid.
692        let mut q = std::collections::VecDeque::new();
693        q.push_back(rtp_with_rid(4, "q", 1, true, &[0x65, 0x11]));
694        q.push_back(rtp_with_rid(4, "h", 2, true, &[0x65, 0x22]));
695        let transport = Arc::new(FakeTransport::with_packets_keep_open(q));
696
697        let endpoint = WhipEndpoint::new(ctx);
698        let (resource, _answer) = endpoint
699            .accept_offer(offer, StreamKey::new("live", "cam"), transport)
700            .unwrap();
701        let pump = tokio::spawn(resource.pump());
702
703        // Base layer `q` → requested key; layer `h` → `cam~h`.
704        let base = wait_for_stream(&engine, &StreamKey::new("live", "cam")).await;
705        let high = wait_for_stream(&engine, &StreamKey::new("live", "cam~h")).await;
706        assert!(base, "base simulcast layer published to the requested key");
707        assert!(high, "second simulcast layer published to a per-rid key");
708
709        pump.abort();
710    }
711
712    async fn wait_for_stream(engine: &Arc<crate::Engine>, key: &StreamKey) -> bool {
713        for _ in 0..200 {
714            if engine.get_stream(key).is_ok() {
715                return true;
716            }
717            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
718        }
719        false
720    }
721
722    #[tokio::test]
723    async fn accept_offer_builds_answer_with_transport_credentials() {
724        let engine = crate::Engine::builder()
725            .application(crate::AppSpec::new("live"))
726            .build();
727        let endpoint = WhipEndpoint::new(IngestContext::new(engine));
728        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
729        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";
730        let (_res, answer) = endpoint
731            .accept_offer(offer, StreamKey::new("live", "web"), transport)
732            .unwrap();
733        assert!(answer.contains("a=ice-ufrag:ufrag"));
734        assert!(answer.contains("a=fingerprint:sha-256 AA:BB"));
735        assert!(answer.contains("a=setup:passive"));
736    }
737
738    #[tokio::test]
739    async fn pump_publishes_idr_then_releases_slot() {
740        let engine = crate::Engine::builder()
741            .application(crate::AppSpec::new("live").gop_cache(4))
742            .build();
743        let key = StreamKey::new("live", "web");
744        let ctx = IngestContext::new(engine.clone());
745
746        let mut q = std::collections::VecDeque::new();
747        q.push_back(rtp_packet(1, 0, true, &[0x65, 0x11])); // single IDR, marker
748        let transport = Arc::new(FakeTransport::with_packets(q));
749
750        let resource = WhipResource {
751            ctx,
752            key: key.clone(),
753            transport,
754            video_pt: 96,
755            audio_pt: None,
756            rid_ext_id: None,
757            simulcast_rids: Vec::new(),
758        };
759        resource.pump().await.unwrap();
760
761        // A complete keyframe was published, so no PLI was needed; the publish
762        // slot is released once the transport drained.
763        assert!(engine.get_stream(&key).is_err());
764    }
765
766    #[tokio::test]
767    async fn pump_requests_keyframe_on_a_depacketize_gap() {
768        let engine = crate::Engine::builder()
769            .application(crate::AppSpec::new("live").gop_cache(4))
770            .build();
771        let ctx = IngestContext::new(engine);
772
773        // A mid FU-A fragment with no start bit forces an OutOfOrder error → PLI.
774        let mut q = std::collections::VecDeque::new();
775        q.push_back(rtp_packet(1, 0, false, &[0x7C, 0x05, 0x11])); // FU-A, S=0
776        let transport = Arc::new(FakeTransport::with_packets(q));
777
778        let resource = WhipResource {
779            ctx,
780            key: StreamKey::new("live", "web2"),
781            transport: transport.clone(),
782            video_pt: 96,
783            audio_pt: None,
784            rid_ext_id: None,
785            simulcast_rids: Vec::new(),
786        };
787        resource.pump().await.unwrap();
788        assert!(
789            !transport.rtcp.lock().await.is_empty(),
790            "a PLI was sent after the depacketize gap"
791        );
792    }
793
794    /// WHIP audio: an RTP packet on the negotiated Opus PT is published as an
795    /// Opus audio frame (one packet per frame, 48 kHz → ms PTS), routed away from
796    /// the H.264 depacketizer.
797    #[tokio::test]
798    async fn pump_routes_opus_audio_onto_the_bus() {
799        let engine = crate::Engine::builder()
800            .application(crate::AppSpec::new("live").gop_cache(8))
801            .build();
802        let key = StreamKey::new("live", "av");
803        let ctx = IngestContext::new(engine.clone());
804
805        // Subscribe before pumping so we observe the published audio frame.
806        let handle = engine.get_stream(&key);
807        assert!(handle.is_err(), "stream not live until pump opens publish");
808
809        let mut q = std::collections::VecDeque::new();
810        // PT 111 (Opus), ts 4800 → 100 ms; payload is an opaque Opus packet.
811        q.push_back(rtp_packet_pt(111, 7, 4800, true, &[0xAA, 0xBB, 0xCC]));
812        let transport = Arc::new(FakeTransport::with_packets(q));
813
814        let resource = WhipResource {
815            ctx,
816            key: key.clone(),
817            transport,
818            video_pt: 96,
819            audio_pt: Some(111),
820            rid_ext_id: None,
821            simulcast_rids: Vec::new(),
822        };
823        // Capture frames via a parallel subscription opened once publishing starts.
824        let pump = tokio::spawn(async move { resource.pump().await });
825        // Give the pump a moment to open the publish + emit, then drain.
826        let _ = pump.await.unwrap();
827        // The stream closed cleanly after the single packet (no panic, no PLI:
828        // audio never drives keyframe requests).
829        assert!(engine.get_stream(&key).is_err());
830    }
831
832    #[tokio::test]
833    async fn whep_egress_packetizes_published_frames_as_rtp() {
834        use crate::FrameFlags;
835        let engine = crate::Engine::builder()
836            .application(crate::AppSpec::new("live").gop_cache(8))
837            .build();
838        let key = StreamKey::new("live", "show");
839
840        // Publish a config + keyframe into the stream via an ingest session.
841        let ctx = IngestContext::new(engine.clone());
842        let session = ctx.open_publish(key.clone()).await.unwrap();
843        let mut cfg = MediaFrame::new_video(
844            0,
845            0,
846            bytes::Bytes::from_static(&[0, 0, 0, 1, 0x67, 0x42]),
847            CodecId::H264,
848            false,
849        );
850        cfg.flags |= FrameFlags::CONFIG;
851        session.publish_frame(cfg).unwrap();
852        session
853            .publish_frame(MediaFrame::new_video(
854                10,
855                10,
856                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88, 0x99]),
857                CodecId::H264,
858                true,
859            ))
860            .unwrap();
861
862        // A WHEP viewer subscribes and pumps; the bus closes when we finish().
863        let whep = WhepEndpoint::new(engine.clone());
864        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
865        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n";
866        let (resource, answer) = whep
867            .accept_offer(offer, key.clone(), transport.clone())
868            .unwrap();
869        assert!(answer.contains("a=sendonly"), "WHEP answer is sendonly");
870
871        let pump = tokio::spawn(resource.pump());
872        // Let the instant-start replay (config + keyframe) flush, then end the stream.
873        for _ in 0..32 {
874            if !transport.sent_rtp.lock().await.is_empty() {
875                break;
876            }
877            tokio::task::yield_now().await;
878        }
879        session.finish().await.unwrap();
880        let _ = pump.await.unwrap();
881
882        let sent = transport.sent_rtp.lock().await;
883        assert!(!sent.is_empty(), "egress sent RTP packets");
884        // The packets parse as RTP with our payload type.
885        let h = RtpHeader::parse(&sent[0]).unwrap();
886        assert_eq!(h.payload_type, 96);
887    }
888
889    /// WHEP audio: when the viewer's offer carries an Opus audio line, published
890    /// Opus audio frames are RTP-packetized on the audio payload type and sent.
891    #[tokio::test]
892    async fn whep_egress_packetizes_opus_audio() {
893        let engine = crate::Engine::builder()
894            .application(crate::AppSpec::new("live").gop_cache(8))
895            .build();
896        let key = StreamKey::new("live", "aud");
897
898        let ctx = IngestContext::new(engine.clone());
899        let session = ctx.open_publish(key.clone()).await.unwrap();
900        // A keyframe (so the GOP replay has video) plus an Opus audio frame.
901        session
902            .publish_frame(MediaFrame::new_video(
903                0,
904                0,
905                bytes::Bytes::from_static(&[0, 0, 0, 1, 0x65, 0x88]),
906                CodecId::H264,
907                true,
908            ))
909            .unwrap();
910        session
911            .publish_frame(MediaFrame::new_audio(
912                20,
913                bytes::Bytes::from_static(&[0xDE, 0xAD, 0xBE, 0xEF]),
914                CodecId::Opus,
915            ))
916            .unwrap();
917
918        let whep = WhepEndpoint::new(engine.clone());
919        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
920        // Offer with both video and Opus audio (PT 111).
921        let offer = "v=0\r\n\
922m=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 H264/90000\r\n\
923m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=rtpmap:111 opus/48000/2\r\n";
924        let (resource, _answer) = whep
925            .accept_offer(offer, key.clone(), transport.clone())
926            .unwrap();
927
928        let pump = tokio::spawn(resource.pump());
929        for _ in 0..64 {
930            if transport
931                .sent_rtp
932                .lock()
933                .await
934                .iter()
935                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111))
936            {
937                break;
938            }
939            tokio::task::yield_now().await;
940        }
941        session.finish().await.unwrap();
942        let _ = pump.await.unwrap();
943
944        let sent = transport.sent_rtp.lock().await;
945        assert!(
946            sent.iter()
947                .any(|p| RtpHeader::parse(p).is_some_and(|h| h.payload_type == 111)),
948            "egress sent an Opus audio RTP packet on PT 111"
949        );
950    }
951
952    #[tokio::test]
953    async fn whep_egress_packetizes_vp9_frames() {
954        let engine = crate::Engine::builder()
955            .application(crate::AppSpec::new("live").gop_cache(8))
956            .build();
957        let key = StreamKey::new("live", "vp9");
958
959        // Publish a VP9 keyframe (no config AU — codec is inferred from the frame).
960        let ctx = IngestContext::new(engine.clone());
961        let session = ctx.open_publish(key.clone()).await.unwrap();
962        let frame_data = bytes::Bytes::from_static(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE]);
963        session
964            .publish_frame(MediaFrame::new_video(
965                0,
966                0,
967                frame_data.clone(),
968                CodecId::VP9,
969                true,
970            ))
971            .unwrap();
972
973        let whep = WhepEndpoint::new(engine.clone());
974        let transport = Arc::new(FakeTransport::with_packets(Default::default()));
975        let offer = "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=rtpmap:96 VP9/90000\r\n";
976        let (resource, _answer) = whep
977            .accept_offer(offer, key.clone(), transport.clone())
978            .unwrap();
979
980        let pump = tokio::spawn(resource.pump());
981        for _ in 0..32 {
982            if !transport.sent_rtp.lock().await.is_empty() {
983                break;
984            }
985            tokio::task::yield_now().await;
986        }
987        session.finish().await.unwrap();
988        let _ = pump.await.unwrap();
989
990        // The egress RTP round-trips back to the original VP9 frame.
991        let sent = transport.sent_rtp.lock().await;
992        assert!(!sent.is_empty(), "VP9 egress sent RTP packets");
993        let mut depack = crate::protocol::rtp::Vp9Depacketizer::new();
994        let mut out = None;
995        for p in sent.iter() {
996            let h = RtpHeader::parse(p).unwrap();
997            if let Some(f) = depack
998                .push(&p[h.payload_offset..], h.marker, h.timestamp)
999                .unwrap()
1000            {
1001                out = Some(f);
1002            }
1003        }
1004        let out = out.expect("VP9 frame completed");
1005        assert_eq!(&out.data[..], &frame_data[..], "VP9 frame reconstructed");
1006        assert!(out.keyframe);
1007    }
1008}