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