diff --git a/crates/wzp-video/src/controller.rs b/crates/wzp-video/src/controller.rs index 0984cf9..3d39a0d 100644 --- a/crates/wzp-video/src/controller.rs +++ b/crates/wzp-video/src/controller.rs @@ -9,6 +9,8 @@ use std::sync::atomic::{AtomicU8, AtomicU32, Ordering::Relaxed}; use wzp_proto::BandwidthEstimator; use wzp_proto::PriorityMode; +use crate::simulcast::LayerTarget; + /// Target parameters for the video encoder. /// /// A `bitrate_kbps` of `0` means video is disabled (not enough bandwidth). @@ -277,6 +279,67 @@ impl VideoQualityController { *self.last_target.lock().unwrap() = smoothed; smoothed } + + /// Run one simulcast controller tick. + /// + /// Returns a 3-element array of [`LayerTarget`] in order low → mid → high. + /// A layer is marked `active = true` when the current video budget can + /// sustain it (including all lower layers). + pub fn tick_simulcast(&self, now_ms: u32) -> [LayerTarget; 3] { + use crate::simulcast::SimulcastLayer; + + let (_audio_budget, video_budget) = self.allocate(); + + let mut result = [ + LayerTarget { + layer: SimulcastLayer::LOW, + active: false, + }, + LayerTarget { + layer: SimulcastLayer::MID, + active: false, + }, + LayerTarget { + layer: SimulcastLayer::HIGH, + active: false, + }, + ]; + + // Cumulative bitrate required to sustain layers up to index i. + let cumulative = [ + SimulcastLayer::LOW.bitrate_kbps, + SimulcastLayer::LOW.bitrate_kbps + SimulcastLayer::MID.bitrate_kbps, + SimulcastLayer::total_bitrate_kbps(), + ]; + + for (i, target) in result.iter_mut().enumerate() { + target.active = video_budget >= cumulative[i]; + } + + // Update internal smoothing state using the highest active layer's + // bitrate as the representative value. + let highest_active = result + .iter() + .rposition(|t| t.active) + .map(|i| cumulative[i]) + .unwrap_or(0); + let raw = if highest_active > 0 { + self.derive_target(highest_active) + } else { + VideoTarget::DISABLED + }; + + let prev = self.last_tick_ms.swap(now_ms, Relaxed); + let dt_ms = if prev == 0 { + 1000 + } else { + now_ms.saturating_sub(prev) + }; + let smoothed = self.smooth(raw, dt_ms); + *self.last_target.lock().unwrap() = smoothed; + + result + } } #[cfg(test)] @@ -432,4 +495,48 @@ mod tests { ctrl.set_mode(PriorityMode::Balanced); assert_eq!(ctrl.encoder_mode(), crate::EncoderMode::Normal); } + + #[test] + fn simulcast_all_layers_at_4mbps() { + // 4 Mbps → ~3600 kbps video budget after audio floor. + let bwe = dummy_bwe(4_000_000); + let ctrl = VideoQualityController::new(bwe); + let layers = ctrl.tick_simulcast(0); + assert!(layers[0].active, "low should be active"); + assert!(layers[1].active, "mid should be active"); + assert!(layers[2].active, "high should be active"); + } + + #[test] + fn simulcast_low_mid_only_at_1mbps() { + // 1 Mbps → ~900 kbps video budget. High needs 3250 total. + let bwe = dummy_bwe(1_000_000); + let ctrl = VideoQualityController::new(bwe); + let layers = ctrl.tick_simulcast(0); + assert!(layers[0].active, "low should be active"); + assert!(layers[1].active, "mid should be active"); + assert!(!layers[2].active, "high should be inactive"); + } + + #[test] + fn simulcast_low_only_at_200kbps() { + // 200 kbps → ~180 kbps video budget. Mid needs 750 total. + let bwe = dummy_bwe(200_000); + let ctrl = VideoQualityController::new(bwe); + let layers = ctrl.tick_simulcast(0); + assert!(layers[0].active, "low should be active"); + assert!(!layers[1].active, "mid should be inactive"); + assert!(!layers[2].active, "high should be inactive"); + } + + #[test] + fn simulcast_no_video_at_20kbps() { + // 20 kbps → ~18 kbps total. Below audio floor. + let bwe = dummy_bwe(20_000); + let ctrl = VideoQualityController::new(bwe); + let layers = ctrl.tick_simulcast(0); + assert!(!layers[0].active, "low should be inactive"); + assert!(!layers[1].active, "mid should be inactive"); + assert!(!layers[2].active, "high should be inactive"); + } } diff --git a/crates/wzp-video/src/lib.rs b/crates/wzp-video/src/lib.rs index 5f0f8d0..713c50e 100644 --- a/crates/wzp-video/src/lib.rs +++ b/crates/wzp-video/src/lib.rs @@ -12,6 +12,7 @@ pub mod encoder_mode; pub mod framer; pub mod mediacodec; pub mod nack; +pub mod simulcast; pub mod videotoolbox; pub use controller::{VideoQualityController, VideoTarget}; @@ -22,6 +23,7 @@ pub use encoder_mode::EncoderMode; pub use framer::{FramedPacket, H264Framer}; pub use mediacodec::{MediaCodecDecoder, MediaCodecEncoder, MediaCodecHevcDecoder, MediaCodecHevcEncoder}; pub use nack::{CachedPacket, NackAction, NackReceiver, NackSender}; +pub use simulcast::{LayerPacket, LayerTarget, SimulcastEncoder, SimulcastLayer}; pub use videotoolbox::{VideoToolboxDecoder, VideoToolboxEncoder, VideoToolboxHevcDecoder, VideoToolboxHevcEncoder}; #[cfg(test)] diff --git a/crates/wzp-video/src/simulcast.rs b/crates/wzp-video/src/simulcast.rs new file mode 100644 index 0000000..624261a --- /dev/null +++ b/crates/wzp-video/src/simulcast.rs @@ -0,0 +1,266 @@ +//! Simulcast encoder — drives 3 independent encoder layers per source. +//! +//! Each layer emits a separate stream tagged by `stream_id`: +//! - 0 = low (480×270, 150 kbps, 15 fps) +//! - 1 = mid (960×540, 600 kbps, 30 fps) +//! - 2 = high (1920×1080, 2500 kbps, 30 fps) +//! +//! The sender activates layers based on available bandwidth. The SFU +//! (T5.6) selects which layer to forward to each receiver. + +use crate::encoder::{VideoEncoder, VideoError, VideoFrame}; + +/// Configuration for one simulcast layer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SimulcastLayer { + /// `stream_id` placed in `MediaHeader` v2. + pub stream_id: u8, + /// Target width in pixels. + pub width: u32, + /// Target height in pixels. + pub height: u32, + /// Target bitrate in kbps. + pub bitrate_kbps: u32, + /// Target frame rate. + pub fps: u8, +} + +impl SimulcastLayer { + /// Low layer — 480×270 @ 150 kbps, 15 fps. + pub const LOW: Self = Self { + stream_id: 0, + width: 480, + height: 270, + bitrate_kbps: 150, + fps: 15, + }; + + /// Mid layer — 960×540 @ 600 kbps, 30 fps. + pub const MID: Self = Self { + stream_id: 1, + width: 960, + height: 540, + bitrate_kbps: 600, + fps: 30, + }; + + /// High layer — 1920×1080 @ 2500 kbps, 30 fps. + pub const HIGH: Self = Self { + stream_id: 2, + width: 1920, + height: 1080, + bitrate_kbps: 2500, + fps: 30, + }; + + /// All three layers in ascending order. + pub const ALL: [Self; 3] = [Self::LOW, Self::MID, Self::HIGH]; + + /// Total bitrate of all layers in kbps. + pub const fn total_bitrate_kbps() -> u32 { + Self::LOW.bitrate_kbps + Self::MID.bitrate_kbps + Self::HIGH.bitrate_kbps + } +} + +/// Active target for one layer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LayerTarget { + pub layer: SimulcastLayer, + pub active: bool, +} + +/// Result of one simulcast encode call. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LayerPacket { + pub stream_id: u8, + pub data: Vec, +} + +/// Simulcast encoder manager. +/// +/// Holds up to three [`VideoEncoder`] instances (one per layer). On each +/// incoming frame it feeds the frame to every active encoder and collects +/// the resulting access units tagged by `stream_id`. +pub struct SimulcastEncoder { + layers: Vec, +} + +struct LayerState { + config: SimulcastLayer, + encoder: Box, + active: bool, +} + +impl SimulcastEncoder { + /// Create a new simulcast encoder from a factory function. + /// + /// `factory` is called once per layer with `(width, height, bitrate_bps)`. + /// On failure for any layer the whole constructor fails. + pub fn new(mut factory: F) -> Result + where + F: FnMut(u32, u32, u32) -> Result, VideoError>, + { + let mut layers = Vec::with_capacity(3); + for cfg in SimulcastLayer::ALL { + let encoder = factory(cfg.width, cfg.height, cfg.bitrate_kbps * 1000)?; + layers.push(LayerState { + config: cfg, + encoder, + active: true, + }); + } + Ok(Self { layers }) + } + + /// Encode one raw frame on all active layers. + /// + /// Returns a vector of `(stream_id, access_unit)` pairs, one per active + /// layer that produced output. + pub fn encode(&mut self, frame: &VideoFrame) -> Result, VideoError> { + let mut out = Vec::with_capacity(self.layers.len()); + for layer in &mut self.layers { + if !layer.active { + continue; + } + let data = layer.encoder.encode(frame)?; + if !data.is_empty() { + out.push(LayerPacket { + stream_id: layer.config.stream_id, + data, + }); + } + } + Ok(out) + } + + /// Request a keyframe on all active layers. + pub fn request_keyframe(&mut self) { + for layer in &mut self.layers { + if layer.active { + layer.encoder.request_keyframe(); + } + } + } + + /// Enable or disable individual layers. + /// + /// `mask` is a 3-bit mask where bit *i* controls layer *i*. + /// bit 0 → low layer + /// bit 1 → mid layer + /// bit 2 → high layer + pub fn set_layer_mask(&mut self, mask: u8) { + for (idx, layer) in self.layers.iter_mut().enumerate() { + layer.active = (mask >> idx) & 1 != 0; + } + } + + /// Current layer mask (3-bit). + pub fn layer_mask(&self) -> u8 { + let mut mask = 0u8; + for (idx, layer) in self.layers.iter().enumerate() { + if layer.active { + mask |= 1 << idx; + } + } + mask + } + +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::encoder::{VideoEncoder, VideoError, VideoFrame}; + + struct DummyEncoder { + stream_id: u8, + force_keyframe: bool, + } + + impl VideoEncoder for DummyEncoder { + fn encode(&mut self, frame: &VideoFrame) -> Result, VideoError> { + let mut out = vec![self.stream_id]; + out.extend_from_slice(&frame.data); + Ok(out) + } + + fn request_keyframe(&mut self) { + self.force_keyframe = true; + } + + fn is_keyframe(&self, _packet: &[u8]) -> bool { + false + } + } + + fn dummy_factory(stream_counter: &mut u8) -> impl FnMut(u32, u32, u32) -> Result, VideoError> + '_ { + move |_w, _h, _br| { + let enc = DummyEncoder { + stream_id: *stream_counter, + force_keyframe: false, + }; + *stream_counter += 1; + Ok(Box::new(enc)) + } + } + + #[test] + fn simulcast_encoder_creates_three_layers() { + let mut counter = 0u8; + let enc = SimulcastEncoder::new(dummy_factory(&mut counter)); + assert!(enc.is_ok()); + let enc = enc.unwrap(); + assert_eq!(enc.layer_mask(), 0b111); + } + + #[test] + fn simulcast_encode_produces_three_packets() { + let mut counter = 0u8; + let mut enc = SimulcastEncoder::new(dummy_factory(&mut counter)).unwrap(); + let frame = VideoFrame::new(1920, 1080, vec![0xAB; 100], 0); + let packets = enc.encode(&frame).unwrap(); + assert_eq!(packets.len(), 3); + assert_eq!(packets[0].stream_id, 0); + assert_eq!(packets[1].stream_id, 1); + assert_eq!(packets[2].stream_id, 2); + } + + #[test] + fn simulcast_layer_mask_disables_layers() { + let mut counter = 0u8; + let mut enc = SimulcastEncoder::new(dummy_factory(&mut counter)).unwrap(); + enc.set_layer_mask(0b101); // low + high, no mid + assert_eq!(enc.layer_mask(), 0b101); + + let frame = VideoFrame::new(1920, 1080, vec![0xCD; 100], 0); + let packets = enc.encode(&frame).unwrap(); + assert_eq!(packets.len(), 2); + assert_eq!(packets[0].stream_id, 0); + assert_eq!(packets[1].stream_id, 2); + } + + #[test] + fn simulcast_request_keyframe_propagates() { + let mut counter = 0u8; + let mut enc = SimulcastEncoder::new(dummy_factory(&mut counter)).unwrap(); + enc.request_keyframe(); + // DummyEncoder sets force_keyframe flag; we can't inspect it directly + // because it's inside the Box, but the call should not panic. + } + + #[test] + fn simulcast_layer_total_bitrate() { + assert_eq!(SimulcastLayer::total_bitrate_kbps(), 150 + 600 + 2500); + } + + #[test] + fn simulcast_all_layers_ordered() { + let all = SimulcastLayer::ALL; + assert_eq!(all[0].stream_id, 0); + assert_eq!(all[1].stream_id, 1); + assert_eq!(all[2].stream_id, 2); + assert_eq!(all[0].width, 480); + assert_eq!(all[1].width, 960); + assert_eq!(all[2].width, 1920); + } +}