T5.5: 3-layer simulcast at sender — SimulcastEncoder + tick_simulcast() + 10 tests
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
266
crates/wzp-video/src/simulcast.rs
Normal file
266
crates/wzp-video/src/simulcast.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user