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::BandwidthEstimator;
|
||||||
use wzp_proto::PriorityMode;
|
use wzp_proto::PriorityMode;
|
||||||
|
|
||||||
|
use crate::simulcast::LayerTarget;
|
||||||
|
|
||||||
/// Target parameters for the video encoder.
|
/// Target parameters for the video encoder.
|
||||||
///
|
///
|
||||||
/// A `bitrate_kbps` of `0` means video is disabled (not enough bandwidth).
|
/// A `bitrate_kbps` of `0` means video is disabled (not enough bandwidth).
|
||||||
@@ -277,6 +279,67 @@ impl VideoQualityController {
|
|||||||
*self.last_target.lock().unwrap() = smoothed;
|
*self.last_target.lock().unwrap() = smoothed;
|
||||||
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)]
|
#[cfg(test)]
|
||||||
@@ -432,4 +495,48 @@ mod tests {
|
|||||||
ctrl.set_mode(PriorityMode::Balanced);
|
ctrl.set_mode(PriorityMode::Balanced);
|
||||||
assert_eq!(ctrl.encoder_mode(), crate::EncoderMode::Normal);
|
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 framer;
|
||||||
pub mod mediacodec;
|
pub mod mediacodec;
|
||||||
pub mod nack;
|
pub mod nack;
|
||||||
|
pub mod simulcast;
|
||||||
pub mod videotoolbox;
|
pub mod videotoolbox;
|
||||||
|
|
||||||
pub use controller::{VideoQualityController, VideoTarget};
|
pub use controller::{VideoQualityController, VideoTarget};
|
||||||
@@ -22,6 +23,7 @@ pub use encoder_mode::EncoderMode;
|
|||||||
pub use framer::{FramedPacket, H264Framer};
|
pub use framer::{FramedPacket, H264Framer};
|
||||||
pub use mediacodec::{MediaCodecDecoder, MediaCodecEncoder, MediaCodecHevcDecoder, MediaCodecHevcEncoder};
|
pub use mediacodec::{MediaCodecDecoder, MediaCodecEncoder, MediaCodecHevcDecoder, MediaCodecHevcEncoder};
|
||||||
pub use nack::{CachedPacket, NackAction, NackReceiver, NackSender};
|
pub use nack::{CachedPacket, NackAction, NackReceiver, NackSender};
|
||||||
|
pub use simulcast::{LayerPacket, LayerTarget, SimulcastEncoder, SimulcastLayer};
|
||||||
pub use videotoolbox::{VideoToolboxDecoder, VideoToolboxEncoder, VideoToolboxHevcDecoder, VideoToolboxHevcEncoder};
|
pub use videotoolbox::{VideoToolboxDecoder, VideoToolboxEncoder, VideoToolboxHevcDecoder, VideoToolboxHevcEncoder};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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