T5.5: 3-layer simulcast at sender — SimulcastEncoder + tick_simulcast() + 10 tests

This commit is contained in:
Siavash Sameni
2026-05-12 14:56:48 +04:00
parent b197651557
commit 2f1a9f74d5
3 changed files with 375 additions and 0 deletions

View File

@@ -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");
}
}

View File

@@ -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)]

View File

@@ -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<u8>,
}
/// 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<LayerState>,
}
struct LayerState {
config: SimulcastLayer,
encoder: Box<dyn VideoEncoder>,
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<F>(mut factory: F) -> Result<Self, VideoError>
where
F: FnMut(u32, u32, u32) -> Result<Box<dyn VideoEncoder>, 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<Vec<LayerPacket>, 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<Vec<u8>, 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<Box<dyn VideoEncoder>, 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);
}
}