Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
pub const ENGINE_BRIDGE_VERSION: u32 = 18;
|
||||
|
||||
const _: () = assert!(ENGINE_BRIDGE_VERSION > 0);
|
||||
|
||||
fn check_engine_bridge_version(version: u32) -> Result<(), String> {
|
||||
if version == ENGINE_BRIDGE_VERSION {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"voice engine bridge version mismatch: host sent {version}, native addon expects {ENGINE_BRIDGE_VERSION}"
|
||||
))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn get_engine_bridge_version() -> u32 {
|
||||
ENGINE_BRIDGE_VERSION
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn assert_engine_bridge_version(version: u32) -> napi::Result<()> {
|
||||
check_engine_bridge_version(version).map_err(napi::Error::from_reason)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matching_version_passes() {
|
||||
assert!(check_engine_bridge_version(ENGINE_BRIDGE_VERSION).is_ok());
|
||||
assert!(assert_engine_bridge_version(ENGINE_BRIDGE_VERSION).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_version_fails_with_both_versions_in_message() {
|
||||
let error = check_engine_bridge_version(ENGINE_BRIDGE_VERSION + 1).unwrap_err();
|
||||
assert!(error.contains("voice engine bridge version mismatch"));
|
||||
assert!(error.contains(&(ENGINE_BRIDGE_VERSION + 1).to_string()));
|
||||
assert!(error.contains(&ENGINE_BRIDGE_VERSION.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_version_fails() {
|
||||
assert!(check_engine_bridge_version(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_surfaces_as_napi_error() {
|
||||
let error = assert_engine_bridge_version(ENGINE_BRIDGE_VERSION - 1).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.reason
|
||||
.contains("voice engine bridge version mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_getter_reports_the_constant() {
|
||||
assert_eq!(get_engine_bridge_version(), ENGINE_BRIDGE_VERSION);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PublishConfig {
|
||||
pub url: String,
|
||||
pub token: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub codec: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl PublishConfig {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.url.trim().is_empty() {
|
||||
return Err("livekit url is empty".into());
|
||||
}
|
||||
if !(self.url.starts_with("ws://") || self.url.starts_with("wss://")) {
|
||||
return Err("livekit url must be ws:// or wss://".into());
|
||||
}
|
||||
if self.token.trim().is_empty() {
|
||||
return Err("livekit token is empty".into());
|
||||
}
|
||||
if self.width < 2 || self.height < 2 {
|
||||
return Err("capture dimensions too small".into());
|
||||
}
|
||||
if !self.width.is_multiple_of(2) || !self.height.is_multiple_of(2) {
|
||||
return Err("capture dimensions must be even".into());
|
||||
}
|
||||
if self.width > 8192 || self.height > 8192 {
|
||||
return Err("capture dimensions too large".into());
|
||||
}
|
||||
if self.fps == 0 {
|
||||
return Err("capture fps must be positive".into());
|
||||
}
|
||||
if !self.codec.trim().is_empty() && canonical_codec_name(&self.codec).is_none() {
|
||||
return Err("unsupported video codec".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub const SUPPORTED_CODECS: &[&str] = &["vp8", "h264", "vp9", "av1", "h265"];
|
||||
|
||||
pub fn canonical_codec_name(name: &str) -> Option<&'static str> {
|
||||
let lower = name.trim().to_ascii_lowercase();
|
||||
if lower == "hevc" {
|
||||
return Some("h265");
|
||||
}
|
||||
SUPPORTED_CODECS.iter().copied().find(|&c| c == lower)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PublisherState {
|
||||
Idle,
|
||||
Connecting,
|
||||
Publishing,
|
||||
Closed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl PublisherState {
|
||||
pub fn accepts_frames(self) -> bool {
|
||||
matches!(self, PublisherState::Publishing)
|
||||
}
|
||||
|
||||
pub fn can_connect(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
PublisherState::Idle | PublisherState::Closed | PublisherState::Failed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> PublishConfig {
|
||||
PublishConfig {
|
||||
url: "wss://sfu.example/rtc".into(),
|
||||
token: "jwt".into(),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
codec: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_config_passes() {
|
||||
assert!(cfg().validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_url() {
|
||||
let mut c = cfg();
|
||||
c.url = "https://sfu".into();
|
||||
assert!(c.validate().is_err());
|
||||
c.url = String::new();
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_token() {
|
||||
let mut c = cfg();
|
||||
c.token = " ".into();
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_odd_or_oob_dimensions() {
|
||||
let mut c = cfg();
|
||||
c.width = 1921;
|
||||
assert!(c.validate().is_err());
|
||||
c.width = 1920;
|
||||
c.height = 0;
|
||||
assert!(c.validate().is_err());
|
||||
c.height = 16384;
|
||||
assert!(c.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_dimension_boundaries_and_rejects_zero_fps() {
|
||||
let mut c = cfg();
|
||||
c.width = 2;
|
||||
c.height = 2;
|
||||
c.fps = 1;
|
||||
assert!(c.validate().is_ok());
|
||||
|
||||
c.width = 8192;
|
||||
c.height = 8192;
|
||||
assert!(c.validate().is_ok());
|
||||
|
||||
c.fps = 0;
|
||||
assert_eq!(
|
||||
c.validate(),
|
||||
Err("capture fps must be positive".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_non_empty_codec_but_allows_empty_default() {
|
||||
let mut c = cfg();
|
||||
c.codec = String::new();
|
||||
assert!(c.validate().is_ok());
|
||||
|
||||
c.codec = " ".into();
|
||||
assert!(c.validate().is_ok());
|
||||
|
||||
c.codec = "h266".into();
|
||||
assert_eq!(c.validate(), Err("unsupported video codec".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_codec_name_accepts_all_five_case_insensitively() {
|
||||
for (input, expected) in [
|
||||
("vp8", "vp8"),
|
||||
(" vp8 ", "vp8"),
|
||||
("VP8", "vp8"),
|
||||
("h264", "h264"),
|
||||
("H264", "h264"),
|
||||
("vp9", "vp9"),
|
||||
("Vp9", "vp9"),
|
||||
("av1", "av1"),
|
||||
("AV1", "av1"),
|
||||
("h265", "h265"),
|
||||
("H265", "h265"),
|
||||
("hevc", "h265"),
|
||||
("HEVC", "h265"),
|
||||
] {
|
||||
assert_eq!(canonical_codec_name(input), Some(expected), "codec {input}");
|
||||
}
|
||||
assert_eq!(SUPPORTED_CODECS.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_codec_name_rejects_empty_and_unknown() {
|
||||
assert_eq!(canonical_codec_name(""), None);
|
||||
assert_eq!(canonical_codec_name("h266"), None);
|
||||
assert_eq!(canonical_codec_name("rubbish"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_gates_frames_and_connect() {
|
||||
assert!(PublisherState::Publishing.accepts_frames());
|
||||
assert!(!PublisherState::Connecting.accepts_frames());
|
||||
assert!(!PublisherState::Idle.accepts_frames());
|
||||
assert!(PublisherState::Idle.can_connect());
|
||||
assert!(PublisherState::Closed.can_connect());
|
||||
assert!(PublisherState::Failed.can_connect());
|
||||
assert!(!PublisherState::Failed.accepts_frames());
|
||||
assert!(!PublisherState::Publishing.can_connect());
|
||||
assert!(!PublisherState::Connecting.can_connect());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::audio::{
|
||||
DEEP_FILTER_NOISE_REDUCTION_LEVEL_MAX, DEEP_FILTER_NOISE_REDUCTION_LEVEL_MIN,
|
||||
clamp_deep_filter_noise_reduction_level,
|
||||
};
|
||||
use df::tract::{DfParams, DfTract, RuntimeParams};
|
||||
use ndarray::Array2;
|
||||
|
||||
pub const DEEP_FILTER_SAMPLE_RATE_HZ: u32 = 48_000;
|
||||
pub const DEEP_FILTER_NUM_CHANNELS: u32 = 1;
|
||||
pub const DEEP_FILTER_FRAME_SAMPLES: usize = 480;
|
||||
|
||||
const SAMPLE_SCALE_I16_TO_F32: f32 = 1.0 / 32_768.0;
|
||||
const SAMPLE_SCALE_F32_TO_I16: f32 = 32_767.0;
|
||||
|
||||
const _: () = assert!(DEEP_FILTER_FRAME_SAMPLES == DEEP_FILTER_SAMPLE_RATE_HZ as usize / 100);
|
||||
const _: () = assert!(DEEP_FILTER_NUM_CHANNELS == 1);
|
||||
|
||||
pub struct DeepFilterProcessor {
|
||||
model: DfTract,
|
||||
input: Array2<f32>,
|
||||
output: Array2<f32>,
|
||||
}
|
||||
|
||||
impl DeepFilterProcessor {
|
||||
pub fn new(noise_reduction_level: f64) -> Result<DeepFilterProcessor, String> {
|
||||
let level_db = clamp_deep_filter_noise_reduction_level(noise_reduction_level);
|
||||
assert!(level_db >= DEEP_FILTER_NOISE_REDUCTION_LEVEL_MIN);
|
||||
assert!(level_db <= DEEP_FILTER_NOISE_REDUCTION_LEVEL_MAX);
|
||||
let params = RuntimeParams::default_with_ch(DEEP_FILTER_NUM_CHANNELS as usize)
|
||||
.with_atten_lim(level_db as f32);
|
||||
let model = DfTract::new(DfParams::default(), ¶ms)
|
||||
.map_err(|error| format!("deep filter model init: {error:#}"))?;
|
||||
if model.sr != DEEP_FILTER_SAMPLE_RATE_HZ as usize {
|
||||
return Err(format!(
|
||||
"deep filter model sample rate {} != {DEEP_FILTER_SAMPLE_RATE_HZ}",
|
||||
model.sr
|
||||
));
|
||||
}
|
||||
if model.ch != DEEP_FILTER_NUM_CHANNELS as usize {
|
||||
return Err(format!(
|
||||
"deep filter model channels {} != {DEEP_FILTER_NUM_CHANNELS}",
|
||||
model.ch
|
||||
));
|
||||
}
|
||||
if model.hop_size != DEEP_FILTER_FRAME_SAMPLES {
|
||||
return Err(format!(
|
||||
"deep filter model hop {} != {DEEP_FILTER_FRAME_SAMPLES}",
|
||||
model.hop_size
|
||||
));
|
||||
}
|
||||
Ok(DeepFilterProcessor {
|
||||
model,
|
||||
input: Array2::zeros((1, DEEP_FILTER_FRAME_SAMPLES)),
|
||||
output: Array2::zeros((1, DEEP_FILTER_FRAME_SAMPLES)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn process_frame(&mut self, samples: &mut [i16]) -> Result<(), String> {
|
||||
assert_eq!(samples.len(), DEEP_FILTER_FRAME_SAMPLES);
|
||||
assert_eq!(self.input.len(), DEEP_FILTER_FRAME_SAMPLES);
|
||||
assert_eq!(self.output.len(), DEEP_FILTER_FRAME_SAMPLES);
|
||||
for (target, sample) in self.input.iter_mut().zip(samples.iter()) {
|
||||
*target = f32::from(*sample) * SAMPLE_SCALE_I16_TO_F32;
|
||||
}
|
||||
self.model
|
||||
.process(self.input.view(), self.output.view_mut())
|
||||
.map_err(|error| format!("deep filter process: {error:#}"))?;
|
||||
for (sample, enhanced) in samples.iter_mut().zip(self.output.iter()) {
|
||||
*sample = sample_f32_to_i16(*enhanced);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_f32_to_i16(sample: f32) -> i16 {
|
||||
if !sample.is_finite() {
|
||||
return 0;
|
||||
}
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
assert!(clamped >= -1.0);
|
||||
assert!(clamped <= 1.0);
|
||||
(clamped * SAMPLE_SCALE_F32_TO_I16) as i16
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn next_noise_sample(seed: &mut u32) -> i16 {
|
||||
*seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
((*seed >> 16) as u16 as i16) / 4
|
||||
}
|
||||
|
||||
fn noise_frame(seed: &mut u32) -> [i16; DEEP_FILTER_FRAME_SAMPLES] {
|
||||
let mut frame = [0i16; DEEP_FILTER_FRAME_SAMPLES];
|
||||
for sample in frame.iter_mut() {
|
||||
*sample = next_noise_sample(seed);
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
fn frame_rms(samples: &[i16]) -> f64 {
|
||||
assert!(!samples.is_empty());
|
||||
let sum_squares: f64 = samples
|
||||
.iter()
|
||||
.map(|sample| {
|
||||
let normalized = f64::from(*sample) / 32_768.0;
|
||||
normalized * normalized
|
||||
})
|
||||
.sum();
|
||||
(sum_squares / samples.len() as f64).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_conversion_holds_the_contract_range() {
|
||||
assert_eq!(sample_f32_to_i16(0.0), 0);
|
||||
assert_eq!(sample_f32_to_i16(1.0), 32_767);
|
||||
assert_eq!(sample_f32_to_i16(-1.0), -32_767);
|
||||
assert_eq!(sample_f32_to_i16(2.0), 32_767);
|
||||
assert_eq!(sample_f32_to_i16(-2.0), -32_767);
|
||||
assert_eq!(sample_f32_to_i16(0.5), 16_383);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_conversion_maps_non_finite_to_silence() {
|
||||
assert_eq!(sample_f32_to_i16(f32::NAN), 0);
|
||||
assert_eq!(sample_f32_to_i16(f32::INFINITY), 0);
|
||||
assert_eq!(sample_f32_to_i16(f32::NEG_INFINITY), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_level_passes_audio_through() {
|
||||
let mut processor = DeepFilterProcessor::new(0.0).expect("embedded model must initialize");
|
||||
let mut seed = 0x2545_f491u32;
|
||||
for _ in 0..5 {
|
||||
let original = noise_frame(&mut seed);
|
||||
let mut processed = original;
|
||||
processor
|
||||
.process_frame(&mut processed)
|
||||
.expect("processing must succeed");
|
||||
for (output, input) in processed.iter().zip(original.iter()) {
|
||||
assert!((i32::from(*output) - i32::from(*input)).abs() <= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_level_attenuates_steady_noise() {
|
||||
let mut processor = DeepFilterProcessor::new(DEEP_FILTER_NOISE_REDUCTION_LEVEL_MAX)
|
||||
.expect("embedded model must initialize");
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
let mut input_rms = 0.0;
|
||||
let mut output_rms = 0.0;
|
||||
for frame_index in 0..30 {
|
||||
let mut frame = noise_frame(&mut seed);
|
||||
let frame_input_rms = frame_rms(&frame);
|
||||
processor
|
||||
.process_frame(&mut frame)
|
||||
.expect("processing must succeed");
|
||||
if frame_index >= 20 {
|
||||
input_rms += frame_input_rms;
|
||||
output_rms += frame_rms(&frame);
|
||||
}
|
||||
}
|
||||
assert!(input_rms > 0.0);
|
||||
assert!(output_rms < input_rms * 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_length_contract_is_enforced() {
|
||||
let mut processor = DeepFilterProcessor::new(DEEP_FILTER_NOISE_REDUCTION_LEVEL_MAX)
|
||||
.expect("embedded model must initialize");
|
||||
let mut short_frame = [0i16; DEEP_FILTER_FRAME_SAMPLES - 1];
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = processor.process_frame(&mut short_frame);
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,643 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub fn push_json_string(out: &mut String, value: &str) {
|
||||
out.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{08}' => out.push_str("\\b"),
|
||||
'\u{0C}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
out.push_str(&format!("\\u{:04x}", c as u32));
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
|
||||
pub enum JsonValue {
|
||||
Str(String),
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
pub fn json_object(fields: &[(&str, JsonValue)]) -> String {
|
||||
let mut out = String::from("{");
|
||||
for (i, (key, value)) in fields.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
push_json_string(&mut out, key);
|
||||
out.push(':');
|
||||
match value {
|
||||
JsonValue::Str(s) => push_json_string(&mut out, s),
|
||||
JsonValue::Raw(r) => out.push_str(r),
|
||||
}
|
||||
}
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
pub fn json_string_array(items: &[String]) -> String {
|
||||
let mut out = String::from("[");
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
push_json_string(&mut out, item);
|
||||
}
|
||||
out.push(']');
|
||||
out
|
||||
}
|
||||
|
||||
pub fn json_u8_array(items: &[u8]) -> String {
|
||||
let mut out = String::from("[");
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&item.to_string());
|
||||
}
|
||||
out.push(']');
|
||||
out
|
||||
}
|
||||
|
||||
pub fn json_raw_array(items: &[String]) -> String {
|
||||
let mut out = String::from("[");
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(item);
|
||||
}
|
||||
out.push(']');
|
||||
out
|
||||
}
|
||||
|
||||
pub fn json_string_map(items: &std::collections::HashMap<String, String>) -> String {
|
||||
let mut entries: Vec<(&String, &String)> = items.iter().collect();
|
||||
entries.sort_by(|left, right| left.0.cmp(right.0));
|
||||
let mut out = String::from("{");
|
||||
for (i, (key, value)) in entries.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
push_json_string(&mut out, key);
|
||||
out.push(':');
|
||||
push_json_string(&mut out, value);
|
||||
}
|
||||
out.push('}');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
mod live {
|
||||
use super::{
|
||||
JsonValue, json_object, json_raw_array, json_string_array, json_string_map, json_u8_array,
|
||||
};
|
||||
use livekit::participant::{
|
||||
ConnectionQuality, LocalParticipant, Participant, RemoteParticipant,
|
||||
};
|
||||
use livekit::publication::{
|
||||
LocalTrackPublication, RemoteTrackPublication, SubscriptionStatus, TrackPublication,
|
||||
};
|
||||
use livekit::track::{TrackKind, TrackSource};
|
||||
use livekit::{DataPacketKind, RoomEvent};
|
||||
|
||||
pub fn track_kind_str(kind: TrackKind) -> &'static str {
|
||||
match kind {
|
||||
TrackKind::Audio => "audio",
|
||||
TrackKind::Video => "video",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn track_source_str(source: TrackSource) -> &'static str {
|
||||
match source {
|
||||
TrackSource::Unknown => "unknown",
|
||||
TrackSource::Camera => "camera",
|
||||
TrackSource::Microphone => "microphone",
|
||||
TrackSource::Screenshare => "screen_share",
|
||||
TrackSource::ScreenshareAudio => "screen_share_audio",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connection_quality_str(quality: ConnectionQuality) -> &'static str {
|
||||
match quality {
|
||||
ConnectionQuality::Excellent => "excellent",
|
||||
ConnectionQuality::Good => "good",
|
||||
ConnectionQuality::Poor => "poor",
|
||||
ConnectionQuality::Lost => "lost",
|
||||
}
|
||||
}
|
||||
|
||||
fn s(value: impl Into<String>) -> JsonValue {
|
||||
JsonValue::Str(value.into())
|
||||
}
|
||||
|
||||
fn b(value: bool) -> JsonValue {
|
||||
JsonValue::Raw(value.to_string())
|
||||
}
|
||||
|
||||
fn subscription_status_str(status: SubscriptionStatus) -> &'static str {
|
||||
match status {
|
||||
SubscriptionStatus::Desired => "desired",
|
||||
SubscriptionStatus::Subscribed => "subscribed",
|
||||
SubscriptionStatus::Unsubscribed => "unsubscribed",
|
||||
}
|
||||
}
|
||||
|
||||
fn participant_snapshot(participant: &Participant) -> String {
|
||||
json_object(&[
|
||||
("sid", s(participant.sid().to_string())),
|
||||
("identity", s(participant.identity().to_string())),
|
||||
("name", s(participant.name())),
|
||||
])
|
||||
}
|
||||
|
||||
fn push_remote_participant_fields(
|
||||
fields: &mut Vec<(&'static str, JsonValue)>,
|
||||
participant: &RemoteParticipant,
|
||||
) {
|
||||
fields.push(("participantSid", s(participant.sid().to_string())));
|
||||
fields.push(("identity", s(participant.identity().to_string())));
|
||||
fields.push(("participantName", s(participant.name())));
|
||||
}
|
||||
|
||||
fn push_local_participant_fields(
|
||||
fields: &mut Vec<(&'static str, JsonValue)>,
|
||||
participant: &LocalParticipant,
|
||||
) {
|
||||
fields.push(("participantSid", s(participant.sid().to_string())));
|
||||
fields.push(("identity", s(participant.identity().to_string())));
|
||||
fields.push(("participantName", s(participant.name())));
|
||||
}
|
||||
|
||||
fn push_participant_fields(
|
||||
fields: &mut Vec<(&'static str, JsonValue)>,
|
||||
participant: &Participant,
|
||||
) {
|
||||
fields.push(("participantSid", s(participant.sid().to_string())));
|
||||
fields.push(("identity", s(participant.identity().to_string())));
|
||||
fields.push(("participantName", s(participant.name())));
|
||||
}
|
||||
|
||||
fn push_remote_publication_fields(
|
||||
fields: &mut Vec<(&'static str, JsonValue)>,
|
||||
publication: &RemoteTrackPublication,
|
||||
) {
|
||||
fields.push(("trackSid", s(publication.sid().to_string())));
|
||||
fields.push(("trackName", s(publication.name())));
|
||||
fields.push(("kind", s(track_kind_str(publication.kind()))));
|
||||
fields.push(("source", s(track_source_str(publication.source()))));
|
||||
fields.push(("muted", b(publication.is_muted())));
|
||||
fields.push(("subscribed", b(publication.is_subscribed())));
|
||||
fields.push((
|
||||
"subscriptionStatus",
|
||||
s(subscription_status_str(publication.subscription_status())),
|
||||
));
|
||||
}
|
||||
|
||||
fn push_local_publication_fields(
|
||||
fields: &mut Vec<(&'static str, JsonValue)>,
|
||||
publication: &LocalTrackPublication,
|
||||
) {
|
||||
fields.push(("trackSid", s(publication.sid().to_string())));
|
||||
fields.push(("trackName", s(publication.name())));
|
||||
fields.push(("kind", s(track_kind_str(publication.kind()))));
|
||||
fields.push(("source", s(track_source_str(publication.source()))));
|
||||
fields.push(("muted", b(publication.is_muted())));
|
||||
}
|
||||
|
||||
fn push_publication_fields(
|
||||
fields: &mut Vec<(&'static str, JsonValue)>,
|
||||
publication: &TrackPublication,
|
||||
) {
|
||||
fields.push(("trackSid", s(publication.sid().to_string())));
|
||||
fields.push(("trackName", s(publication.name())));
|
||||
fields.push(("kind", s(track_kind_str(publication.kind()))));
|
||||
fields.push(("source", s(track_source_str(publication.source()))));
|
||||
fields.push(("muted", b(publication.is_muted())));
|
||||
}
|
||||
|
||||
fn remote_track_payload(
|
||||
participant: &RemoteParticipant,
|
||||
publication: &RemoteTrackPublication,
|
||||
) -> String {
|
||||
let mut fields = Vec::new();
|
||||
push_remote_participant_fields(&mut fields, participant);
|
||||
push_remote_publication_fields(&mut fields, publication);
|
||||
json_object(&fields)
|
||||
}
|
||||
|
||||
const CONNECTED_ROSTER_PARTICIPANTS_MAX: usize = 1024;
|
||||
const CONNECTED_ROSTER_TRACKS_PER_PARTICIPANT_MAX: usize = 16;
|
||||
|
||||
fn connected_payload(
|
||||
participants_with_tracks: &[(RemoteParticipant, Vec<RemoteTrackPublication>)],
|
||||
) -> String {
|
||||
let participant_count = participants_with_tracks
|
||||
.len()
|
||||
.min(CONNECTED_ROSTER_PARTICIPANTS_MAX);
|
||||
let mut entries = Vec::with_capacity(participant_count);
|
||||
for (participant, publications) in participants_with_tracks
|
||||
.iter()
|
||||
.take(CONNECTED_ROSTER_PARTICIPANTS_MAX)
|
||||
{
|
||||
let track_count = publications
|
||||
.len()
|
||||
.min(CONNECTED_ROSTER_TRACKS_PER_PARTICIPANT_MAX);
|
||||
let mut tracks = Vec::with_capacity(track_count);
|
||||
for publication in publications
|
||||
.iter()
|
||||
.take(CONNECTED_ROSTER_TRACKS_PER_PARTICIPANT_MAX)
|
||||
{
|
||||
tracks.push(remote_track_payload(participant, publication));
|
||||
}
|
||||
entries.push(json_object(&[
|
||||
("sid", s(participant.sid().to_string())),
|
||||
("identity", s(participant.identity().to_string())),
|
||||
("name", s(participant.name())),
|
||||
("tracks", JsonValue::Raw(json_raw_array(&tracks))),
|
||||
]));
|
||||
}
|
||||
assert!(entries.len() <= CONNECTED_ROSTER_PARTICIPANTS_MAX);
|
||||
json_object(&[("participants", JsonValue::Raw(json_raw_array(&entries)))])
|
||||
}
|
||||
|
||||
fn local_track_payload(
|
||||
participant: &LocalParticipant,
|
||||
publication: &LocalTrackPublication,
|
||||
) -> String {
|
||||
let mut fields = Vec::new();
|
||||
push_local_participant_fields(&mut fields, participant);
|
||||
push_local_publication_fields(&mut fields, publication);
|
||||
json_object(&fields)
|
||||
}
|
||||
|
||||
pub fn map_room_event(event: &RoomEvent) -> Option<(&'static str, String)> {
|
||||
map_participant_lifecycle_room_event(event)
|
||||
.or_else(|| map_participant_profile_room_event(event))
|
||||
.or_else(|| map_track_room_event(event))
|
||||
.or_else(|| map_local_track_room_event(event))
|
||||
.or_else(|| map_connection_room_event(event))
|
||||
}
|
||||
|
||||
fn map_participant_lifecycle_room_event(event: &RoomEvent) -> Option<(&'static str, String)> {
|
||||
match event {
|
||||
RoomEvent::ParticipantConnected(p) | RoomEvent::ParticipantActive(p) => Some((
|
||||
"participantJoined",
|
||||
json_object(&[
|
||||
("sid", s(p.sid().to_string())),
|
||||
("identity", s(p.identity().to_string())),
|
||||
("name", s(p.name())),
|
||||
]),
|
||||
)),
|
||||
RoomEvent::ParticipantDisconnected(p) => Some((
|
||||
"participantLeft",
|
||||
json_object(&[
|
||||
("sid", s(p.sid().to_string())),
|
||||
("identity", s(p.identity().to_string())),
|
||||
("name", s(p.name())),
|
||||
]),
|
||||
)),
|
||||
RoomEvent::ParticipantNameChanged {
|
||||
participant,
|
||||
old_name,
|
||||
name,
|
||||
} => Some((
|
||||
"participantNameChanged",
|
||||
json_object(&[
|
||||
("sid", s(participant.sid().to_string())),
|
||||
("identity", s(participant.identity().to_string())),
|
||||
("oldName", s(old_name.to_string())),
|
||||
("name", s(name.to_string())),
|
||||
]),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_participant_profile_room_event(event: &RoomEvent) -> Option<(&'static str, String)> {
|
||||
match event {
|
||||
RoomEvent::ParticipantMetadataChanged {
|
||||
participant,
|
||||
old_metadata,
|
||||
metadata,
|
||||
} => Some((
|
||||
"participantMetadataChanged",
|
||||
json_object(&[
|
||||
("sid", s(participant.sid().to_string())),
|
||||
("identity", s(participant.identity().to_string())),
|
||||
("name", s(participant.name())),
|
||||
("oldMetadata", s(old_metadata.to_string())),
|
||||
("metadata", s(metadata.to_string())),
|
||||
(
|
||||
"attributes",
|
||||
JsonValue::Raw(json_string_map(&participant.attributes())),
|
||||
),
|
||||
]),
|
||||
)),
|
||||
RoomEvent::ParticipantAttributesChanged {
|
||||
participant,
|
||||
changed_attributes,
|
||||
} => Some((
|
||||
"participantAttributesChanged",
|
||||
json_object(&[
|
||||
("sid", s(participant.sid().to_string())),
|
||||
("identity", s(participant.identity().to_string())),
|
||||
("name", s(participant.name())),
|
||||
(
|
||||
"attributes",
|
||||
JsonValue::Raw(json_string_map(&participant.attributes())),
|
||||
),
|
||||
(
|
||||
"changedAttributes",
|
||||
JsonValue::Raw(json_string_map(changed_attributes)),
|
||||
),
|
||||
]),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_track_room_event(event: &RoomEvent) -> Option<(&'static str, String)> {
|
||||
match event {
|
||||
RoomEvent::TrackSubscribed {
|
||||
publication,
|
||||
participant,
|
||||
..
|
||||
} => Some((
|
||||
"trackSubscribed",
|
||||
remote_track_payload(participant, publication),
|
||||
)),
|
||||
RoomEvent::TrackUnsubscribed {
|
||||
publication,
|
||||
participant,
|
||||
..
|
||||
} => Some((
|
||||
"trackUnsubscribed",
|
||||
remote_track_payload(participant, publication),
|
||||
)),
|
||||
RoomEvent::TrackSubscriptionFailed {
|
||||
participant,
|
||||
error,
|
||||
track_sid,
|
||||
} => {
|
||||
let mut fields = Vec::new();
|
||||
push_remote_participant_fields(&mut fields, participant);
|
||||
if let Some(publication) = participant.get_track_publication(track_sid) {
|
||||
push_remote_publication_fields(&mut fields, &publication);
|
||||
} else {
|
||||
fields.push(("trackSid", s(track_sid.to_string())));
|
||||
}
|
||||
fields.push(("error", s(format!("{error}"))));
|
||||
Some(("trackSubscriptionFailed", json_object(&fields)))
|
||||
}
|
||||
RoomEvent::TrackPublished {
|
||||
publication,
|
||||
participant,
|
||||
} => Some((
|
||||
"trackPublished",
|
||||
remote_track_payload(participant, publication),
|
||||
)),
|
||||
RoomEvent::TrackUnpublished {
|
||||
publication,
|
||||
participant,
|
||||
} => Some((
|
||||
"trackUnpublished",
|
||||
remote_track_payload(participant, publication),
|
||||
)),
|
||||
RoomEvent::TrackMuted {
|
||||
participant,
|
||||
publication,
|
||||
} => {
|
||||
let mut fields = Vec::new();
|
||||
push_participant_fields(&mut fields, participant);
|
||||
push_publication_fields(&mut fields, publication);
|
||||
Some(("trackMuted", json_object(&fields)))
|
||||
}
|
||||
RoomEvent::TrackUnmuted {
|
||||
participant,
|
||||
publication,
|
||||
} => {
|
||||
let mut fields = Vec::new();
|
||||
push_participant_fields(&mut fields, participant);
|
||||
push_publication_fields(&mut fields, publication);
|
||||
Some(("trackUnmuted", json_object(&fields)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_connection_room_event(event: &RoomEvent) -> Option<(&'static str, String)> {
|
||||
match event {
|
||||
RoomEvent::ActiveSpeakersChanged { speakers } => {
|
||||
let sids: Vec<String> = speakers.iter().map(|p| p.sid().to_string()).collect();
|
||||
let participants: Vec<String> = speakers.iter().map(participant_snapshot).collect();
|
||||
Some((
|
||||
"activeSpeakers",
|
||||
json_object(&[
|
||||
("sids", JsonValue::Raw(json_string_array(&sids))),
|
||||
(
|
||||
"participants",
|
||||
JsonValue::Raw(json_raw_array(&participants)),
|
||||
),
|
||||
]),
|
||||
))
|
||||
}
|
||||
RoomEvent::ConnectionQualityChanged {
|
||||
quality,
|
||||
participant,
|
||||
} => Some((
|
||||
"connectionQuality",
|
||||
json_object(&[
|
||||
("sid", s(participant.sid().to_string())),
|
||||
("identity", s(participant.identity().to_string())),
|
||||
("name", s(participant.name())),
|
||||
("quality", s(connection_quality_str(*quality))),
|
||||
]),
|
||||
)),
|
||||
RoomEvent::DataReceived {
|
||||
payload,
|
||||
topic,
|
||||
kind,
|
||||
participant,
|
||||
} => Some((
|
||||
"dataReceived",
|
||||
data_received_payload(
|
||||
payload.as_ref().as_slice(),
|
||||
topic.as_deref(),
|
||||
kind,
|
||||
participant.as_ref(),
|
||||
),
|
||||
)),
|
||||
RoomEvent::E2eeStateChanged { participant, state } => Some((
|
||||
"e2eeState",
|
||||
json_object(&[
|
||||
("sid", s(participant.sid().to_string())),
|
||||
("identity", s(participant.identity().to_string())),
|
||||
("name", s(participant.name())),
|
||||
("state", s(format!("{state:?}").to_lowercase())),
|
||||
]),
|
||||
)),
|
||||
RoomEvent::ConnectionStateChanged(state) => Some((
|
||||
"connectionState",
|
||||
json_object(&[("state", s(format!("{state:?}").to_lowercase()))]),
|
||||
)),
|
||||
RoomEvent::Disconnected { reason } => Some((
|
||||
"disconnected",
|
||||
json_object(&[("reason", s(format!("{reason:?}").to_lowercase()))]),
|
||||
)),
|
||||
RoomEvent::Connected {
|
||||
participants_with_tracks,
|
||||
} => Some(("connected", connected_payload(participants_with_tracks))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn data_received_payload(
|
||||
payload: &[u8],
|
||||
topic: Option<&str>,
|
||||
kind: &DataPacketKind,
|
||||
participant: Option<&RemoteParticipant>,
|
||||
) -> String {
|
||||
let mut fields = vec![
|
||||
("payloadBytes", JsonValue::Raw(json_u8_array(payload))),
|
||||
(
|
||||
"reliable",
|
||||
JsonValue::Raw(matches!(kind, DataPacketKind::Reliable).to_string()),
|
||||
),
|
||||
(
|
||||
"kind",
|
||||
s(match kind {
|
||||
DataPacketKind::Reliable => "reliable",
|
||||
DataPacketKind::Lossy => "lossy",
|
||||
}),
|
||||
),
|
||||
];
|
||||
if let Some(topic) = topic {
|
||||
fields.push(("topic", s(topic.to_string())));
|
||||
}
|
||||
if let Ok(payload_text) = std::str::from_utf8(payload) {
|
||||
fields.push(("payloadText", s(payload_text.to_string())));
|
||||
}
|
||||
if let Some(participant) = participant {
|
||||
push_remote_participant_fields(&mut fields, participant);
|
||||
}
|
||||
json_object(&fields)
|
||||
}
|
||||
|
||||
fn map_local_track_room_event(event: &RoomEvent) -> Option<(&'static str, String)> {
|
||||
match event {
|
||||
RoomEvent::LocalTrackPublished {
|
||||
publication,
|
||||
participant,
|
||||
..
|
||||
} => Some((
|
||||
"localTrackPublished",
|
||||
local_track_payload(participant, publication),
|
||||
)),
|
||||
RoomEvent::LocalTrackUnpublished {
|
||||
publication,
|
||||
participant,
|
||||
} => Some((
|
||||
"localTrackUnpublished",
|
||||
local_track_payload(participant, publication),
|
||||
)),
|
||||
RoomEvent::LocalTrackRepublished {
|
||||
previous_sid,
|
||||
publication,
|
||||
participant,
|
||||
..
|
||||
} => {
|
||||
let mut fields = Vec::new();
|
||||
push_local_participant_fields(&mut fields, participant);
|
||||
fields.push(("previousTrackSid", s(previous_sid.to_string())));
|
||||
push_local_publication_fields(&mut fields, publication);
|
||||
Some(("localTrackRepublished", json_object(&fields)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
pub use live::{map_room_event, track_kind_str, track_source_str};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escapes_quote_backslash_and_control_chars() {
|
||||
let mut out = String::new();
|
||||
push_json_string(&mut out, "a\"b\\c\nd\te");
|
||||
assert_eq!(out, "\"a\\\"b\\\\c\\nd\\te\"");
|
||||
|
||||
let mut ctrl = String::new();
|
||||
push_json_string(&mut ctrl, "\u{01}");
|
||||
assert_eq!(ctrl, "\"\\u0001\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_plain_ascii_and_unicode_untouched() {
|
||||
let mut out = String::new();
|
||||
push_json_string(&mut out, "PA_abc123");
|
||||
assert_eq!(out, "\"PA_abc123\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_object_preserves_order_and_mixes_str_and_raw() {
|
||||
let json = json_object(&[
|
||||
("sid", JsonValue::Str("PA_1".into())),
|
||||
(
|
||||
"sids",
|
||||
JsonValue::Raw(json_string_array(&["PA_1".into(), "PA_2".into()])),
|
||||
),
|
||||
]);
|
||||
assert_eq!(json, "{\"sid\":\"PA_1\",\"sids\":[\"PA_1\",\"PA_2\"]}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
#[test]
|
||||
fn track_source_strings_match_livekit_js_sources() {
|
||||
use livekit::track::TrackSource;
|
||||
|
||||
assert_eq!(track_source_str(TrackSource::Camera), "camera");
|
||||
assert_eq!(track_source_str(TrackSource::Microphone), "microphone");
|
||||
assert_eq!(track_source_str(TrackSource::Screenshare), "screen_share");
|
||||
assert_eq!(
|
||||
track_source_str(TrackSource::ScreenshareAudio),
|
||||
"screen_share_audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_and_array() {
|
||||
assert_eq!(json_object(&[]), "{}");
|
||||
assert_eq!(json_string_array(&[]), "[]");
|
||||
assert_eq!(json_u8_array(&[]), "[]");
|
||||
assert_eq!(json_raw_array(&[]), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_u8_array_serializes_bytes_as_numbers() {
|
||||
assert_eq!(json_u8_array(&[0, 1, 127, 255]), "[0,1,127,255]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_array_preserves_prebuilt_json_objects() {
|
||||
let items = vec![
|
||||
json_object(&[("sid", JsonValue::Str("PA_1".into()))]),
|
||||
json_object(&[("sid", JsonValue::Str("PA_2".into()))]),
|
||||
];
|
||||
assert_eq!(
|
||||
json_raw_array(&items),
|
||||
"[{\"sid\":\"PA_1\"},{\"sid\":\"PA_2\"}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use futures_core::Stream;
|
||||
use napi::tokio;
|
||||
use napi::tokio::task::AbortHandle;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::future::poll_fn;
|
||||
use std::pin::pin;
|
||||
|
||||
pub const INBOUND_FORWARDERS_MAX: usize = 512;
|
||||
|
||||
struct ForwarderEntry {
|
||||
participant_sid: String,
|
||||
handle: AbortHandle,
|
||||
}
|
||||
|
||||
pub struct InboundForwarderRegistry {
|
||||
entries: Mutex<HashMap<String, ForwarderEntry>>,
|
||||
}
|
||||
|
||||
impl InboundForwarderRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.lock().len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn contains(&self, track_sid: &str) -> bool {
|
||||
self.entries.lock().contains_key(track_sid)
|
||||
}
|
||||
|
||||
pub fn register(&self, track_sid: &str, participant_sid: &str, handle: AbortHandle) -> bool {
|
||||
if track_sid.is_empty() {
|
||||
handle.abort();
|
||||
return false;
|
||||
}
|
||||
let mut entries = self.entries.lock();
|
||||
if let Some(previous) = entries.remove(track_sid) {
|
||||
previous.handle.abort();
|
||||
}
|
||||
if entries.len() >= INBOUND_FORWARDERS_MAX {
|
||||
drop(entries);
|
||||
handle.abort();
|
||||
eprintln!(
|
||||
"webrtc-sender: inbound forwarder registry at cap {INBOUND_FORWARDERS_MAX}; \
|
||||
refusing forwarder for track {track_sid}"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
entries.insert(
|
||||
track_sid.to_string(),
|
||||
ForwarderEntry {
|
||||
participant_sid: participant_sid.to_string(),
|
||||
handle,
|
||||
},
|
||||
);
|
||||
assert!(entries.len() <= INBOUND_FORWARDERS_MAX);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn cancel(&self, track_sid: &str) {
|
||||
let removed = self.entries.lock().remove(track_sid);
|
||||
if let Some(entry) = removed {
|
||||
entry.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_for_participant(&self, participant_sid: &str) {
|
||||
let aborted: Vec<ForwarderEntry> = {
|
||||
let mut entries = self.entries.lock();
|
||||
let matching: Vec<String> = entries
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.participant_sid == participant_sid)
|
||||
.map(|(track_sid, _)| track_sid.clone())
|
||||
.collect();
|
||||
matching
|
||||
.into_iter()
|
||||
.filter_map(|track_sid| entries.remove(&track_sid))
|
||||
.collect()
|
||||
};
|
||||
for entry in aborted {
|
||||
entry.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
let drained: Vec<ForwarderEntry> = {
|
||||
let mut entries = self.entries.lock();
|
||||
entries.drain().map(|(_, entry)| entry).collect()
|
||||
};
|
||||
for entry in drained {
|
||||
entry.handle.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InboundForwarderRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_drain_forwarder<S, F>(stream: S, mut on_item: F) -> AbortHandle
|
||||
where
|
||||
S: Stream + Send + 'static,
|
||||
S::Item: Send,
|
||||
F: FnMut(S::Item) + Send + 'static,
|
||||
{
|
||||
let task = tokio::spawn(async move {
|
||||
let mut stream = pin!(stream);
|
||||
loop {
|
||||
let item = poll_fn(|cx| stream.as_mut().poll_next(cx)).await;
|
||||
match item {
|
||||
Some(item) => on_item(item),
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
task.abort_handle()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
struct CloseSignal {
|
||||
closed: AtomicBool,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
impl CloseSignal {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
closed: AtomicBool::new(false),
|
||||
notify: Notify::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn fire(&self) {
|
||||
self.closed.store(true, Ordering::SeqCst);
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
async fn wait(&self) {
|
||||
if self.is_closed() {
|
||||
return;
|
||||
}
|
||||
self.notify.notified().await;
|
||||
assert!(self.is_closed());
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeVideoStream {
|
||||
signal: Arc<CloseSignal>,
|
||||
}
|
||||
|
||||
impl Stream for FakeVideoStream {
|
||||
type Item = u64;
|
||||
|
||||
fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakeVideoStream {
|
||||
fn drop(&mut self) {
|
||||
self.signal.fire();
|
||||
}
|
||||
}
|
||||
|
||||
struct CountedStream {
|
||||
items: VecDeque<u64>,
|
||||
count: Arc<AtomicUsize>,
|
||||
done: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl Stream for CountedStream {
|
||||
type Item = u64;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
match self.items.pop_front() {
|
||||
Some(item) => {
|
||||
self.count.fetch_add(1, Ordering::SeqCst);
|
||||
Poll::Ready(Some(item))
|
||||
}
|
||||
None => {
|
||||
self.done.notify_one();
|
||||
Poll::Ready(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> tokio::runtime::Runtime {
|
||||
Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.build()
|
||||
.expect("tokio runtime")
|
||||
}
|
||||
|
||||
fn dispatch_track_subscribed(
|
||||
registry: &InboundForwarderRegistry,
|
||||
track_sid: &str,
|
||||
participant_sid: &str,
|
||||
) -> Arc<CloseSignal> {
|
||||
let (signal, registered) = dispatch_with_outcome(registry, track_sid, participant_sid);
|
||||
assert!(registered);
|
||||
signal
|
||||
}
|
||||
|
||||
fn dispatch_with_outcome(
|
||||
registry: &InboundForwarderRegistry,
|
||||
track_sid: &str,
|
||||
participant_sid: &str,
|
||||
) -> (Arc<CloseSignal>, bool) {
|
||||
let signal = CloseSignal::new();
|
||||
let stream = FakeVideoStream {
|
||||
signal: signal.clone(),
|
||||
};
|
||||
let handle = spawn_drain_forwarder(stream, |_frame| {});
|
||||
let registered = registry.register(track_sid, participant_sid, handle);
|
||||
(signal, registered)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teardown_symmetry_closes_the_stream() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let registry = InboundForwarderRegistry::new();
|
||||
let signal = dispatch_track_subscribed(®istry, "TR_x", "PA_one");
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert!(!signal.is_closed());
|
||||
|
||||
registry.cancel("TR_x");
|
||||
assert_eq!(registry.len(), 0);
|
||||
|
||||
signal.wait().await;
|
||||
assert!(signal.is_closed());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_subscribe_unsubscribe_never_accumulates() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let registry = InboundForwarderRegistry::new();
|
||||
let cycles = 32usize;
|
||||
let mut signals = Vec::with_capacity(cycles);
|
||||
|
||||
for _ in 0..cycles {
|
||||
let signal = dispatch_track_subscribed(®istry, "TR_x", "PA_one");
|
||||
assert_eq!(registry.len(), 1);
|
||||
registry.cancel("TR_x");
|
||||
assert_eq!(registry.len(), 0);
|
||||
signal.wait().await;
|
||||
signals.push(signal);
|
||||
}
|
||||
|
||||
assert_eq!(registry.len(), 0);
|
||||
let closed_count = signals.iter().filter(|signal| signal.is_closed()).count();
|
||||
assert_eq!(closed_count, cycles);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_subscribe_keeps_one_live_forwarder() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let registry = InboundForwarderRegistry::new();
|
||||
let first_signal = dispatch_track_subscribed(®istry, "TR_x", "PA_one");
|
||||
let second_signal = dispatch_track_subscribed(®istry, "TR_x", "PA_one");
|
||||
|
||||
assert_eq!(registry.len(), 1);
|
||||
first_signal.wait().await;
|
||||
assert!(first_signal.is_closed());
|
||||
assert!(!second_signal.is_closed());
|
||||
|
||||
registry.cancel("TR_x");
|
||||
assert_eq!(registry.len(), 0);
|
||||
second_signal.wait().await;
|
||||
assert!(second_signal.is_closed());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_disconnect_tears_down_all_forwarders() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let registry = InboundForwarderRegistry::new();
|
||||
let video_signal = dispatch_track_subscribed(®istry, "TR_video", "PA_one");
|
||||
let audio_signal = dispatch_track_subscribed(®istry, "TR_audio", "PA_one");
|
||||
let other_signal = dispatch_track_subscribed(®istry, "TR_other", "PA_two");
|
||||
assert_eq!(registry.len(), 3);
|
||||
|
||||
registry.cancel_for_participant("PA_one");
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert!(registry.contains("TR_other"));
|
||||
|
||||
video_signal.wait().await;
|
||||
audio_signal.wait().await;
|
||||
assert!(video_signal.is_closed());
|
||||
assert!(audio_signal.is_closed());
|
||||
assert!(!other_signal.is_closed());
|
||||
|
||||
registry.clear();
|
||||
assert_eq!(registry.len(), 0);
|
||||
other_signal.wait().await;
|
||||
assert!(other_signal.is_closed());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_tears_down_every_forwarder() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let registry = InboundForwarderRegistry::new();
|
||||
let mut signals = Vec::new();
|
||||
for index in 0..8 {
|
||||
let track_sid = format!("TR_{index}");
|
||||
signals.push(dispatch_track_subscribed(®istry, &track_sid, "PA_one"));
|
||||
}
|
||||
assert_eq!(registry.len(), 8);
|
||||
|
||||
registry.clear();
|
||||
assert_eq!(registry.len(), 0);
|
||||
|
||||
for signal in &signals {
|
||||
signal.wait().await;
|
||||
}
|
||||
assert!(signals.iter().all(|signal| signal.is_closed()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_forwarder_invokes_callback_per_item() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let polled = Arc::new(AtomicUsize::new(0));
|
||||
let polled_in_task = polled.clone();
|
||||
let done = Arc::new(Notify::new());
|
||||
let stream = CountedStream {
|
||||
items: VecDeque::from(vec![1u64, 2, 3, 4]),
|
||||
count: count.clone(),
|
||||
done: done.clone(),
|
||||
};
|
||||
let _handle = spawn_drain_forwarder(stream, move |_item| {
|
||||
polled_in_task.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
done.notified().await;
|
||||
|
||||
assert_eq!(count.load(Ordering::SeqCst), 4);
|
||||
assert_eq!(polled.load(Ordering::SeqCst), 4);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_refuses_and_closes_forwarder_at_cap() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let registry = InboundForwarderRegistry::new();
|
||||
for index in 0..INBOUND_FORWARDERS_MAX {
|
||||
let track_sid = format!("TR_{index}");
|
||||
let (_signal, registered) = dispatch_with_outcome(®istry, &track_sid, "PA_one");
|
||||
assert!(registered);
|
||||
}
|
||||
assert_eq!(registry.len(), INBOUND_FORWARDERS_MAX);
|
||||
|
||||
let (overflow_signal, registered) =
|
||||
dispatch_with_outcome(®istry, "TR_overflow", "PA_one");
|
||||
assert!(!registered);
|
||||
assert_eq!(registry.len(), INBOUND_FORWARDERS_MAX);
|
||||
assert!(!registry.contains("TR_overflow"));
|
||||
|
||||
overflow_signal.wait().await;
|
||||
assert!(overflow_signal.is_closed());
|
||||
|
||||
registry.clear();
|
||||
assert_eq!(registry.len(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_refuses_and_closes_forwarder_for_empty_sid() {
|
||||
let runtime = runtime();
|
||||
runtime.block_on(async {
|
||||
let registry = InboundForwarderRegistry::new();
|
||||
let (signal, registered) = dispatch_with_outcome(®istry, "", "PA_one");
|
||||
assert!(!registered);
|
||||
assert_eq!(registry.len(), 0);
|
||||
|
||||
signal.wait().await;
|
||||
assert!(signal.is_closed());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#![allow(unsafe_op_in_unsafe_fn)]
|
||||
#![cfg_attr(
|
||||
not(all(feature = "publisher", feature = "camera-native")),
|
||||
allow(dead_code)
|
||||
)]
|
||||
|
||||
mod audio;
|
||||
mod bridge_version;
|
||||
mod camera;
|
||||
mod camera_background;
|
||||
mod config;
|
||||
mod deep_filter;
|
||||
mod events;
|
||||
mod hardware_encoder;
|
||||
mod inbound_forwarder;
|
||||
mod mask_refine;
|
||||
mod native_camera;
|
||||
mod person_segmentation;
|
||||
mod send_control;
|
||||
mod speaking;
|
||||
mod stats;
|
||||
mod texture_source;
|
||||
mod yuv;
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
mod engine;
|
||||
|
||||
#[cfg(feature = "bench-internals")]
|
||||
pub mod bench_internals {
|
||||
pub use crate::audio::DEEP_FILTER_NOISE_REDUCTION_LEVEL_MAX;
|
||||
pub use crate::deep_filter::{DEEP_FILTER_FRAME_SAMPLES, DeepFilterProcessor};
|
||||
pub use crate::mask_refine::MaskRefiner;
|
||||
|
||||
pub struct BlurScratch(crate::camera_background::BlurScratch);
|
||||
|
||||
impl BlurScratch {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
Self(crate::camera_background::BlurScratch::new(width, height))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blur_plane_masked(
|
||||
plane: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
mask: &[u8],
|
||||
radius_pass: usize,
|
||||
scratch: &mut BlurScratch,
|
||||
) {
|
||||
let mask = crate::camera_background::plane_mask(mask, width, 1);
|
||||
crate::camera_background::blur_plane_masked(
|
||||
plane,
|
||||
width,
|
||||
height,
|
||||
mask,
|
||||
radius_pass,
|
||||
&mut scratch.0,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn composite_masked_plane(
|
||||
plane: &mut [u8],
|
||||
background: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
mask: &[u8],
|
||||
) {
|
||||
let mask = crate::camera_background::plane_mask(mask, width, 1);
|
||||
crate::camera_background::composite_masked_plane(plane, background, width, height, mask);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const FRAME_EDGE_MAX: usize = 8192;
|
||||
const MASK_REFINE_DOWNSAMPLE: usize = 4;
|
||||
const GUIDED_FILTER_RADIUS_LOW: usize = 4;
|
||||
const GUIDED_FILTER_EPSILON: f32 = 1e-4;
|
||||
const TEMPORAL_COMBINE_RATIO: f32 = 0.7;
|
||||
const TEMPORAL_UNCERTAINTY_C1: f32 = 5.68842;
|
||||
const TEMPORAL_UNCERTAINTY_C2: f32 = -0.748699;
|
||||
const TEMPORAL_UNCERTAINTY_C3: f32 = -57.8051;
|
||||
const TEMPORAL_UNCERTAINTY_C4: f32 = 291.309;
|
||||
const TEMPORAL_UNCERTAINTY_C5: f32 = -624.717;
|
||||
const SHAPE_SMOOTHSTEP_EDGE_LOW: f32 = 0.55;
|
||||
const SHAPE_SMOOTHSTEP_EDGE_HIGH: f32 = 0.85;
|
||||
const LUT_LEN: usize = 256;
|
||||
|
||||
pub struct MaskRefiner {
|
||||
width: usize,
|
||||
height: usize,
|
||||
low_width: usize,
|
||||
low_height: usize,
|
||||
previous_mask: Vec<u8>,
|
||||
previous_mask_valid: bool,
|
||||
temporal_weight_lut: [u16; LUT_LEN],
|
||||
shape_lut: [u8; LUT_LEN],
|
||||
column_fixed: Vec<u32>,
|
||||
guide_low: Vec<f32>,
|
||||
mask_low: Vec<f32>,
|
||||
mean_guide: Vec<f32>,
|
||||
mean_mask: Vec<f32>,
|
||||
corr_guide_guide: Vec<f32>,
|
||||
corr_guide_mask: Vec<f32>,
|
||||
coeff_a: Vec<f32>,
|
||||
coeff_b: Vec<f32>,
|
||||
scratch: Vec<f32>,
|
||||
}
|
||||
|
||||
impl MaskRefiner {
|
||||
pub fn new(width: usize, height: usize) -> Self {
|
||||
assert!(width >= 2);
|
||||
assert!(height >= 2);
|
||||
assert!(width <= FRAME_EDGE_MAX);
|
||||
assert!(height <= FRAME_EDGE_MAX);
|
||||
let low_width = (width / MASK_REFINE_DOWNSAMPLE).max(1);
|
||||
let low_height = (height / MASK_REFINE_DOWNSAMPLE).max(1);
|
||||
let low_len = low_width * low_height;
|
||||
let mut column_fixed = vec![0u32; width];
|
||||
for (x, slot) in column_fixed.iter_mut().enumerate() {
|
||||
*slot = bilinear_fixed_coord(x, width, low_width);
|
||||
}
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
low_width,
|
||||
low_height,
|
||||
previous_mask: vec![0; width * height],
|
||||
previous_mask_valid: false,
|
||||
temporal_weight_lut: temporal_weight_lut(),
|
||||
shape_lut: shape_lut(),
|
||||
column_fixed,
|
||||
guide_low: vec![0.0; low_len],
|
||||
mask_low: vec![0.0; low_len],
|
||||
mean_guide: vec![0.0; low_len],
|
||||
mean_mask: vec![0.0; low_len],
|
||||
corr_guide_guide: vec![0.0; low_len],
|
||||
corr_guide_mask: vec![0.0; low_len],
|
||||
coeff_a: vec![0.0; low_len],
|
||||
coeff_b: vec![0.0; low_len],
|
||||
scratch: vec![0.0; low_len],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refine(&mut self, luma: &[u8], mask: &mut [u8]) {
|
||||
assert!(luma.len() >= self.width * self.height);
|
||||
assert!(mask.len() >= self.width * self.height);
|
||||
self.blend_temporal(mask);
|
||||
self.downsample(luma, mask);
|
||||
self.close_mask_low();
|
||||
self.solve_guided_coefficients();
|
||||
self.apply_guided_coefficients(luma, mask);
|
||||
}
|
||||
|
||||
fn blend_temporal(&mut self, mask: &mut [u8]) {
|
||||
let len = self.width * self.height;
|
||||
assert!(mask.len() >= len);
|
||||
assert_eq!(self.previous_mask.len(), len);
|
||||
if self.previous_mask_valid {
|
||||
for (current, previous) in mask[..len].iter_mut().zip(self.previous_mask.iter()) {
|
||||
let new_value = i32::from(*current);
|
||||
let weight = i32::from(self.temporal_weight_lut[usize::from(*current)]);
|
||||
let delta = (i32::from(*previous) - new_value) * weight;
|
||||
*current = (new_value + ((delta + 128) >> 8)).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
self.previous_mask.copy_from_slice(&mask[..len]);
|
||||
self.previous_mask_valid = true;
|
||||
}
|
||||
|
||||
fn downsample(&mut self, luma: &[u8], mask: &[u8]) {
|
||||
assert_eq!(self.guide_low.len(), self.low_width * self.low_height);
|
||||
assert_eq!(self.mask_low.len(), self.guide_low.len());
|
||||
for low_y in 0..self.low_height {
|
||||
let y_start = low_y * MASK_REFINE_DOWNSAMPLE;
|
||||
let y_end = (y_start + MASK_REFINE_DOWNSAMPLE).min(self.height);
|
||||
for low_x in 0..self.low_width {
|
||||
let x_start = low_x * MASK_REFINE_DOWNSAMPLE;
|
||||
let x_end = (x_start + MASK_REFINE_DOWNSAMPLE).min(self.width);
|
||||
let mut guide_sum: u32 = 0;
|
||||
let mut mask_sum: u32 = 0;
|
||||
for y in y_start..y_end {
|
||||
let row = y * self.width;
|
||||
for x in x_start..x_end {
|
||||
guide_sum += u32::from(luma[row + x]);
|
||||
mask_sum += u32::from(mask[row + x]);
|
||||
}
|
||||
}
|
||||
let count = ((y_end - y_start) * (x_end - x_start)) as f32;
|
||||
assert!(count >= 1.0);
|
||||
let low_offset = low_y * self.low_width + low_x;
|
||||
self.guide_low[low_offset] = guide_sum as f32 / (count * 255.0);
|
||||
self.mask_low[low_offset] = mask_sum as f32 / (count * 255.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_mask_low(&mut self) {
|
||||
morph_pass_low(
|
||||
&self.mask_low,
|
||||
&mut self.scratch,
|
||||
&mut self.coeff_a,
|
||||
self.low_width,
|
||||
self.low_height,
|
||||
f32::max,
|
||||
);
|
||||
morph_pass_low(
|
||||
&self.coeff_a,
|
||||
&mut self.scratch,
|
||||
&mut self.mask_low,
|
||||
self.low_width,
|
||||
self.low_height,
|
||||
f32::min,
|
||||
);
|
||||
}
|
||||
|
||||
fn solve_guided_coefficients(&mut self) {
|
||||
let len = self.low_width * self.low_height;
|
||||
assert_eq!(self.coeff_a.len(), len);
|
||||
assert_eq!(self.coeff_b.len(), len);
|
||||
let radius = GUIDED_FILTER_RADIUS_LOW;
|
||||
let w = self.low_width;
|
||||
let h = self.low_height;
|
||||
box_filter_low(
|
||||
&self.guide_low,
|
||||
&mut self.scratch,
|
||||
&mut self.mean_guide,
|
||||
w,
|
||||
h,
|
||||
radius,
|
||||
);
|
||||
box_filter_low(
|
||||
&self.mask_low,
|
||||
&mut self.scratch,
|
||||
&mut self.mean_mask,
|
||||
w,
|
||||
h,
|
||||
radius,
|
||||
);
|
||||
for i in 0..len {
|
||||
self.coeff_a[i] = self.guide_low[i] * self.guide_low[i];
|
||||
self.coeff_b[i] = self.guide_low[i] * self.mask_low[i];
|
||||
}
|
||||
box_filter_low(
|
||||
&self.coeff_a,
|
||||
&mut self.scratch,
|
||||
&mut self.corr_guide_guide,
|
||||
w,
|
||||
h,
|
||||
radius,
|
||||
);
|
||||
box_filter_low(
|
||||
&self.coeff_b,
|
||||
&mut self.scratch,
|
||||
&mut self.corr_guide_mask,
|
||||
w,
|
||||
h,
|
||||
radius,
|
||||
);
|
||||
for i in 0..len {
|
||||
let variance = self.corr_guide_guide[i] - self.mean_guide[i] * self.mean_guide[i];
|
||||
let covariance = self.corr_guide_mask[i] - self.mean_guide[i] * self.mean_mask[i];
|
||||
let a = covariance / (variance.max(0.0) + GUIDED_FILTER_EPSILON);
|
||||
self.coeff_a[i] = a;
|
||||
self.coeff_b[i] = self.mean_mask[i] - a * self.mean_guide[i];
|
||||
}
|
||||
box_filter_low(
|
||||
&self.coeff_a,
|
||||
&mut self.scratch,
|
||||
&mut self.mean_guide,
|
||||
w,
|
||||
h,
|
||||
radius,
|
||||
);
|
||||
box_filter_low(
|
||||
&self.coeff_b,
|
||||
&mut self.scratch,
|
||||
&mut self.mean_mask,
|
||||
w,
|
||||
h,
|
||||
radius,
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_guided_coefficients(&self, luma: &[u8], mask: &mut [u8]) {
|
||||
assert!(luma.len() >= self.width * self.height);
|
||||
assert!(mask.len() >= self.width * self.height);
|
||||
let low_w = self.low_width;
|
||||
for y in 0..self.height {
|
||||
let row_fixed = bilinear_fixed_coord(y, self.height, self.low_height);
|
||||
let sy = (row_fixed / 256) as usize;
|
||||
let fy = (row_fixed % 256) as f32 / 256.0;
|
||||
let sy_next = (sy + 1).min(self.low_height - 1);
|
||||
let row = y * self.width;
|
||||
for x in 0..self.width {
|
||||
let col_fixed = self.column_fixed[x];
|
||||
let sx = (col_fixed / 256) as usize;
|
||||
let fx = (col_fixed % 256) as f32 / 256.0;
|
||||
let sx_next = (sx + 1).min(low_w - 1);
|
||||
let a = bilinear_sample(&self.mean_guide, low_w, sx, sx_next, sy, sy_next, fx, fy);
|
||||
let b = bilinear_sample(&self.mean_mask, low_w, sx, sx_next, sy, sy_next, fx, fy);
|
||||
let q = a * (f32::from(luma[row + x]) / 255.0) + b;
|
||||
let shaped = (q * 255.0 + 0.5).clamp(0.0, 255.0) as usize;
|
||||
mask[row + x] = self.shape_lut[shaped.min(LUT_LEN - 1)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bilinear_fixed_coord(index: usize, full_len: usize, low_len: usize) -> u32 {
|
||||
assert!(full_len >= 1);
|
||||
assert!(low_len >= 1);
|
||||
if full_len == 1 {
|
||||
return 0;
|
||||
}
|
||||
(index * (low_len - 1) * 256 / (full_len - 1)) as u32
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub(crate) fn bilinear_sample(
|
||||
plane: &[f32],
|
||||
width: usize,
|
||||
sx: usize,
|
||||
sx_next: usize,
|
||||
sy: usize,
|
||||
sy_next: usize,
|
||||
fx: f32,
|
||||
fy: f32,
|
||||
) -> f32 {
|
||||
assert!(sy * width + sx_next < plane.len());
|
||||
assert!(sy_next * width + sx_next < plane.len());
|
||||
let top = plane[sy * width + sx] * (1.0 - fx) + plane[sy * width + sx_next] * fx;
|
||||
let bottom = plane[sy_next * width + sx] * (1.0 - fx) + plane[sy_next * width + sx_next] * fx;
|
||||
top * (1.0 - fy) + bottom * fy
|
||||
}
|
||||
|
||||
fn temporal_uncertainty(probability: f32) -> f32 {
|
||||
assert!(probability >= 0.0);
|
||||
assert!(probability <= 1.0);
|
||||
let x = (probability - 0.5) * (probability - 0.5);
|
||||
let polynomial = x
|
||||
* (TEMPORAL_UNCERTAINTY_C1
|
||||
+ x * (TEMPORAL_UNCERTAINTY_C2
|
||||
+ x * (TEMPORAL_UNCERTAINTY_C3
|
||||
+ x * (TEMPORAL_UNCERTAINTY_C4 + x * TEMPORAL_UNCERTAINTY_C5))));
|
||||
1.0 - polynomial.min(1.0)
|
||||
}
|
||||
|
||||
fn temporal_weight_lut() -> [u16; LUT_LEN] {
|
||||
let mut lut = [0u16; LUT_LEN];
|
||||
for (value, slot) in lut.iter_mut().enumerate() {
|
||||
let probability = value as f32 / 255.0;
|
||||
let weight = temporal_uncertainty(probability) * TEMPORAL_COMBINE_RATIO;
|
||||
assert!(weight >= 0.0);
|
||||
assert!(weight <= 1.0);
|
||||
*slot = (weight * 256.0 + 0.5) as u16;
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
fn shape_lut() -> [u8; LUT_LEN] {
|
||||
let span = SHAPE_SMOOTHSTEP_EDGE_HIGH - SHAPE_SMOOTHSTEP_EDGE_LOW;
|
||||
assert!(span > 0.0);
|
||||
let mut lut = [0u8; LUT_LEN];
|
||||
for (value, slot) in lut.iter_mut().enumerate() {
|
||||
let probability = value as f32 / 255.0;
|
||||
let t = ((probability - SHAPE_SMOOTHSTEP_EDGE_LOW) / span).clamp(0.0, 1.0);
|
||||
let smooth = t * t * (3.0 - 2.0 * t);
|
||||
*slot = (smooth * 255.0 + 0.5) as u8;
|
||||
}
|
||||
assert_eq!(lut[0], 0);
|
||||
assert_eq!(lut[LUT_LEN - 1], 255);
|
||||
lut
|
||||
}
|
||||
|
||||
pub(crate) fn box_filter_low(
|
||||
src: &[f32],
|
||||
scratch: &mut [f32],
|
||||
dst: &mut [f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
radius: usize,
|
||||
) {
|
||||
assert!(width >= 1);
|
||||
assert!(height >= 1);
|
||||
assert!(src.len() >= width * height);
|
||||
assert!(scratch.len() >= width * height);
|
||||
assert!(dst.len() >= width * height);
|
||||
box_filter_rows_low(src, scratch, width, height, radius);
|
||||
box_filter_columns_low(scratch, dst, width, height, radius);
|
||||
}
|
||||
|
||||
fn box_filter_rows_low(src: &[f32], dst: &mut [f32], width: usize, height: usize, radius: usize) {
|
||||
assert!(width >= 1);
|
||||
assert!(src.len() >= width * height);
|
||||
for y in 0..height {
|
||||
let row = y * width;
|
||||
let mut start = 0usize;
|
||||
let mut end = radius.min(width - 1);
|
||||
let mut sum: f32 = src[row..=row + end].iter().sum();
|
||||
for x in 0..width {
|
||||
dst[row + x] = sum / ((end - start + 1) as f32);
|
||||
let next_end = (x + 1 + radius).min(width - 1);
|
||||
if next_end > end {
|
||||
sum += src[row + next_end];
|
||||
end = next_end;
|
||||
}
|
||||
let next_start = (x + 1).saturating_sub(radius);
|
||||
if next_start > start {
|
||||
sum -= src[row + start];
|
||||
start = next_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn box_filter_columns_low(
|
||||
src: &[f32],
|
||||
dst: &mut [f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
radius: usize,
|
||||
) {
|
||||
assert!(width >= 1);
|
||||
assert!(width <= FRAME_EDGE_MAX);
|
||||
assert!(height >= 1);
|
||||
let mut sums = [0.0f32; FRAME_EDGE_MAX];
|
||||
let mut start = 0usize;
|
||||
let mut end = radius.min(height - 1);
|
||||
for y in 0..=end {
|
||||
let row = y * width;
|
||||
for x in 0..width {
|
||||
sums[x] += src[row + x];
|
||||
}
|
||||
}
|
||||
for y in 0..height {
|
||||
let scale = 1.0 / ((end - start + 1) as f32);
|
||||
let row = y * width;
|
||||
for x in 0..width {
|
||||
dst[row + x] = sums[x] * scale;
|
||||
}
|
||||
let next_end = (y + 1 + radius).min(height - 1);
|
||||
if next_end > end {
|
||||
let next_row = next_end * width;
|
||||
for x in 0..width {
|
||||
sums[x] += src[next_row + x];
|
||||
}
|
||||
end = next_end;
|
||||
}
|
||||
let next_start = (y + 1).saturating_sub(radius);
|
||||
if next_start > start {
|
||||
let previous_row = start * width;
|
||||
for x in 0..width {
|
||||
sums[x] -= src[previous_row + x];
|
||||
}
|
||||
start = next_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn morph_pass_low(
|
||||
src: &[f32],
|
||||
scratch: &mut [f32],
|
||||
dst: &mut [f32],
|
||||
width: usize,
|
||||
height: usize,
|
||||
select: fn(f32, f32) -> f32,
|
||||
) {
|
||||
assert!(width >= 1);
|
||||
assert!(height >= 1);
|
||||
assert!(src.len() >= width * height);
|
||||
assert!(scratch.len() >= width * height);
|
||||
assert!(dst.len() >= width * height);
|
||||
for y in 0..height {
|
||||
let row = y * width;
|
||||
for x in 0..width {
|
||||
let left = src[row + x.saturating_sub(1)];
|
||||
let right = src[row + (x + 1).min(width - 1)];
|
||||
scratch[row + x] = select(select(left, src[row + x]), right);
|
||||
}
|
||||
}
|
||||
for y in 0..height {
|
||||
let above = y.saturating_sub(1) * width;
|
||||
let below = (y + 1).min(height - 1) * width;
|
||||
let row = y * width;
|
||||
for x in 0..width {
|
||||
dst[row + x] = select(
|
||||
select(scratch[above + x], scratch[row + x]),
|
||||
scratch[below + x],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gradient_luma(width: usize, height: usize) -> Vec<u8> {
|
||||
let mut luma = vec![0u8; width * height];
|
||||
for (index, value) in luma.iter_mut().enumerate() {
|
||||
*value = ((index % width) * 255 / (width - 1).max(1)) as u8;
|
||||
}
|
||||
luma
|
||||
}
|
||||
|
||||
fn left_half_mask(width: usize, height: usize) -> Vec<u8> {
|
||||
let mut mask = vec![0u8; width * height];
|
||||
for y in 0..height {
|
||||
for x in 0..width / 2 {
|
||||
mask[y * width + x] = 255;
|
||||
}
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_weight_lut_smooths_uncertain_values_and_trusts_confident_ones() {
|
||||
let lut = temporal_weight_lut();
|
||||
|
||||
assert!(lut[128] >= 170);
|
||||
assert!(lut[128] <= 182);
|
||||
assert!(lut[0] <= 8);
|
||||
assert!(lut[255] <= 8);
|
||||
assert!(lut[64] > lut[16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_blend_pulls_uncertain_pixels_toward_previous_mask() {
|
||||
let mut refiner = MaskRefiner::new(8, 8);
|
||||
let mut first = vec![128u8; 64];
|
||||
refiner.blend_temporal(&mut first);
|
||||
let mut second = vec![128u8; 64];
|
||||
second[0] = 255;
|
||||
second[1] = 130;
|
||||
|
||||
refiner.blend_temporal(&mut second);
|
||||
|
||||
assert_eq!(second[0], 255);
|
||||
assert!(second[1] < 130);
|
||||
assert_eq!(second[63], 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_blend_first_frame_passes_mask_through_unchanged() {
|
||||
let mut refiner = MaskRefiner::new(8, 8);
|
||||
let mut mask = vec![37u8; 64];
|
||||
|
||||
refiner.blend_temporal(&mut mask);
|
||||
|
||||
assert!(mask.iter().all(|value| *value == 37));
|
||||
assert!(refiner.previous_mask_valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_lut_is_monotonic_and_saturates_at_both_ends() {
|
||||
let lut = shape_lut();
|
||||
|
||||
for value in 1..LUT_LEN {
|
||||
assert!(lut[value] >= lut[value - 1]);
|
||||
}
|
||||
assert_eq!(lut[(255.0 * SHAPE_SMOOTHSTEP_EDGE_LOW) as usize - 4], 0);
|
||||
assert_eq!(lut[(255.0 * SHAPE_SMOOTHSTEP_EDGE_HIGH) as usize + 4], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_keeps_solid_person_and_background_regions_saturated() {
|
||||
let width = 64usize;
|
||||
let height = 48usize;
|
||||
let mut refiner = MaskRefiner::new(width, height);
|
||||
let luma = {
|
||||
let mut luma = vec![32u8; width * height];
|
||||
for y in 0..height {
|
||||
for x in width / 2..width {
|
||||
luma[y * width + x] = 224;
|
||||
}
|
||||
}
|
||||
luma
|
||||
};
|
||||
let mut mask = left_half_mask(width, height);
|
||||
|
||||
refiner.refine(&luma, &mut mask);
|
||||
|
||||
assert_eq!(mask[24 * width], 255);
|
||||
assert_eq!(mask[24 * width + 4], 255);
|
||||
assert_eq!(mask[24 * width + width - 1], 0);
|
||||
assert_eq!(mask[24 * width + width - 5], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_snaps_mask_transition_to_the_luma_edge() {
|
||||
let width = 64usize;
|
||||
let height = 48usize;
|
||||
let mut refiner = MaskRefiner::new(width, height);
|
||||
let mut luma = vec![16u8; width * height];
|
||||
for y in 0..height {
|
||||
for x in 0..width / 2 {
|
||||
luma[y * width + x] = 240;
|
||||
}
|
||||
}
|
||||
let mut blurry_mask = vec![0u8; width * height];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let distance = (width as i32 / 2 - x as i32).clamp(-12, 12);
|
||||
blurry_mask[y * width + x] = (127 + distance * 10).clamp(0, 255) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
refiner.refine(&luma, &mut blurry_mask);
|
||||
|
||||
let row = 24 * width;
|
||||
assert!(blurry_mask[row + width / 2 - 8] > 220);
|
||||
assert!(blurry_mask[row + width / 2 + 8] < 35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_fills_small_holes_inside_the_person() {
|
||||
let width = 64usize;
|
||||
let height = 48usize;
|
||||
let mut refiner = MaskRefiner::new(width, height);
|
||||
let luma = vec![128u8; width * height];
|
||||
let mut mask = vec![255u8; width * height];
|
||||
mask[24 * width + 32] = 0;
|
||||
|
||||
refiner.refine(&luma, &mut mask);
|
||||
|
||||
assert!(mask[24 * width + 32] > 200);
|
||||
assert_eq!(mask[0], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_handles_minimum_dimensions_without_panicking() {
|
||||
let mut refiner = MaskRefiner::new(2, 2);
|
||||
let luma = vec![128u8; 4];
|
||||
let mut mask = vec![255u8; 4];
|
||||
|
||||
refiner.refine(&luma, &mut mask);
|
||||
|
||||
assert_eq!(mask.len(), 4);
|
||||
assert!(mask.iter().all(|value| *value == 255));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_filter_low_preserves_constant_planes_exactly() {
|
||||
let width = 9usize;
|
||||
let height = 7usize;
|
||||
let src = vec![0.625f32; width * height];
|
||||
let mut scratch = vec![0.0f32; width * height];
|
||||
let mut dst = vec![0.0f32; width * height];
|
||||
|
||||
box_filter_low(&src, &mut scratch, &mut dst, width, height, 4);
|
||||
|
||||
for value in dst {
|
||||
assert!((value - 0.625).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_close_removes_single_pixel_pits_and_keeps_plateaus() {
|
||||
let width = 8usize;
|
||||
let height = 8usize;
|
||||
let mut src = vec![1.0f32; width * height];
|
||||
src[3 * width + 3] = 0.0;
|
||||
let mut scratch = vec![0.0f32; width * height];
|
||||
let mut maxed = vec![0.0f32; width * height];
|
||||
let mut closed = vec![0.0f32; width * height];
|
||||
|
||||
morph_pass_low(&src, &mut scratch, &mut maxed, width, height, f32::max);
|
||||
morph_pass_low(&maxed, &mut scratch, &mut closed, width, height, f32::min);
|
||||
|
||||
assert!(closed[3 * width + 3] > 0.99);
|
||||
assert!(closed[0] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_converges_to_stable_mask_over_repeated_identical_frames() {
|
||||
let width = 32usize;
|
||||
let height = 24usize;
|
||||
let mut refiner = MaskRefiner::new(width, height);
|
||||
let luma = gradient_luma(width, height);
|
||||
let raw = left_half_mask(width, height);
|
||||
let mut previous_output = vec![0u8; width * height];
|
||||
for iteration in 0..8 {
|
||||
let mut mask = raw.clone();
|
||||
refiner.refine(&luma, &mut mask);
|
||||
if iteration == 7 {
|
||||
let drift: i32 = mask
|
||||
.iter()
|
||||
.zip(previous_output.iter())
|
||||
.map(|(a, b)| (i32::from(*a) - i32::from(*b)).abs())
|
||||
.sum();
|
||||
assert!(drift <= (width * height) as i32);
|
||||
}
|
||||
previous_output.copy_from_slice(&mask);
|
||||
}
|
||||
assert_eq!(previous_output[12 * width], 255);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#![allow(dead_code)]
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
use livekit::webrtc::video_frame::{VideoBuffer, VideoFrame, VideoRotation, native::NativeBuffer};
|
||||
#[cfg(feature = "publisher")]
|
||||
use livekit::webrtc::video_source::native::NativeVideoSource;
|
||||
use napi_derive::napi;
|
||||
|
||||
pub const NATIVE_CAMERA_FRAME_QUEUE_CAPACITY: usize = 3;
|
||||
const MIN_NATIVE_CAMERA_EDGE: u32 = 2;
|
||||
const MAX_NATIVE_CAMERA_EDGE: u32 = 8192;
|
||||
const TRANSPORT_CV_PIXEL_BUFFER: &str = "cvPixelBuffer";
|
||||
const TRANSPORT_D3D11_TEXTURE: &str = "d3d11Texture";
|
||||
const TRANSPORT_DMABUF: &str = "dmabuf";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NativeCameraTransport {
|
||||
CvPixelBuffer,
|
||||
D3d11Texture,
|
||||
Dmabuf,
|
||||
}
|
||||
|
||||
impl NativeCameraTransport {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::CvPixelBuffer => TRANSPORT_CV_PIXEL_BUFFER,
|
||||
Self::D3d11Texture => TRANSPORT_D3D11_TEXTURE,
|
||||
Self::Dmabuf => TRANSPORT_DMABUF,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn required_transports() -> &'static [NativeCameraTransport] {
|
||||
const TRANSPORTS: &[NativeCameraTransport] = &[
|
||||
NativeCameraTransport::CvPixelBuffer,
|
||||
NativeCameraTransport::D3d11Texture,
|
||||
NativeCameraTransport::Dmabuf,
|
||||
];
|
||||
assert_eq!(TRANSPORTS.len(), 3);
|
||||
TRANSPORTS
|
||||
}
|
||||
|
||||
pub fn required_transport_names() -> [&'static str; 3] {
|
||||
let transports = required_transports();
|
||||
[
|
||||
transports[0].as_str(),
|
||||
transports[1].as_str(),
|
||||
transports[2].as_str(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn platform_native_backgrounds_available() -> bool {
|
||||
platform_unavailable_reason().is_none()
|
||||
}
|
||||
|
||||
pub fn camera_backgrounds_available() -> bool {
|
||||
cfg!(feature = "camera-native")
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn has_native_camera_backgrounds() -> bool {
|
||||
camera_backgrounds_available()
|
||||
}
|
||||
|
||||
pub fn platform_unavailable_reason() -> Option<&'static str> {
|
||||
Some(match std::env::consts::OS {
|
||||
"macos" => "macOS AVFoundation CVPixelBuffer camera backend is not compiled",
|
||||
"windows" => "Windows Media Foundation D3D11 camera backend is not compiled",
|
||||
"linux" => "Linux PipeWire/V4L2 dmabuf camera backend is not compiled",
|
||||
_ => "native platform-buffer camera backend is unsupported on this platform",
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_native_frame_dimensions(width: u32, height: u32) -> bool {
|
||||
if width < MIN_NATIVE_CAMERA_EDGE {
|
||||
return false;
|
||||
}
|
||||
if height < MIN_NATIVE_CAMERA_EDGE {
|
||||
return false;
|
||||
}
|
||||
if !width.is_multiple_of(2) {
|
||||
return false;
|
||||
}
|
||||
if !height.is_multiple_of(2) {
|
||||
return false;
|
||||
}
|
||||
width <= MAX_NATIVE_CAMERA_EDGE && height <= MAX_NATIVE_CAMERA_EDGE
|
||||
}
|
||||
|
||||
pub fn unavailable_error() -> String {
|
||||
let reason = platform_unavailable_reason().unwrap_or("native camera backend unavailable");
|
||||
format!(
|
||||
"native camera backgrounds require platform camera buffers ({}, {}, {}): {reason}",
|
||||
TRANSPORT_CV_PIXEL_BUFFER, TRANSPORT_D3D11_TEXTURE, TRANSPORT_DMABUF
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "publisher")]
|
||||
pub fn publish_native_buffer(
|
||||
source: &NativeVideoSource,
|
||||
buffer: NativeBuffer,
|
||||
timestamp_us: i64,
|
||||
) -> bool {
|
||||
assert!(timestamp_us >= 0);
|
||||
let width = buffer.width();
|
||||
let height = buffer.height();
|
||||
if !validate_native_frame_dimensions(width, height) {
|
||||
return false;
|
||||
}
|
||||
source.capture_frame(&VideoFrame {
|
||||
rotation: VideoRotation::VideoRotation0,
|
||||
timestamp_us,
|
||||
frame_metadata: None,
|
||||
buffer,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn required_transports_are_stable() {
|
||||
assert_eq!(
|
||||
required_transport_names(),
|
||||
["cvPixelBuffer", "d3d11Texture", "dmabuf"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_capacity_stays_bounded_for_realtime_capture() {
|
||||
assert_eq!(NATIVE_CAMERA_FRAME_QUEUE_CAPACITY, 3);
|
||||
assert!(NATIVE_CAMERA_FRAME_QUEUE_CAPACITY < 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_background_capability_tracks_native_camera_feature() {
|
||||
assert_eq!(
|
||||
camera_backgrounds_available(),
|
||||
cfg!(feature = "camera-native")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_frame_dimensions_require_even_reasonable_sizes() {
|
||||
assert!(validate_native_frame_dimensions(1280, 720));
|
||||
assert!(!validate_native_frame_dimensions(0, 720));
|
||||
assert!(!validate_native_frame_dimensions(1280, 1));
|
||||
assert!(!validate_native_frame_dimensions(1279, 720));
|
||||
assert!(!validate_native_frame_dimensions(1280, 721));
|
||||
assert!(!validate_native_frame_dimensions(16_384, 720));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_error_names_all_required_native_transports() {
|
||||
let error = unavailable_error();
|
||||
assert!(error.contains("cvPixelBuffer"));
|
||||
assert!(error.contains("d3d11Texture"));
|
||||
assert!(error.contains("dmabuf"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const PERSON_MASK_BACKGROUND: u8 = 0;
|
||||
pub const PERSON_MASK_PERSON: u8 = 255;
|
||||
pub const SEGMENTATION_FRAME_BUDGET_MS: u64 = 12;
|
||||
pub const SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX: u32 = 30;
|
||||
|
||||
pub trait PersonMaskSource {
|
||||
fn mask_into(&mut self, frame: &crate::yuv::I420, mask: &mut [u8]) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SegmentationQualityGovernor {
|
||||
consecutive_slow_frames: u32,
|
||||
downgraded: bool,
|
||||
}
|
||||
|
||||
impl SegmentationQualityGovernor {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn record_frame_duration_ms(&mut self, duration_ms: u64) -> bool {
|
||||
assert!(self.consecutive_slow_frames < SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX);
|
||||
if self.downgraded {
|
||||
return false;
|
||||
}
|
||||
if duration_ms <= SEGMENTATION_FRAME_BUDGET_MS {
|
||||
self.consecutive_slow_frames = 0;
|
||||
return false;
|
||||
}
|
||||
self.consecutive_slow_frames += 1;
|
||||
assert!(self.consecutive_slow_frames <= SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX);
|
||||
if self.consecutive_slow_frames < SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX {
|
||||
return false;
|
||||
}
|
||||
self.consecutive_slow_frames = 0;
|
||||
self.downgraded = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_person_mask_source(width: u32, height: u32) -> Option<Box<dyn PersonMaskSource>> {
|
||||
assert!(width >= 2);
|
||||
assert!(height >= 2);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(source) = vision::VisionPersonMaskSource::new(width, height) {
|
||||
return Some(Box::new(source) as Box<dyn PersonMaskSource>);
|
||||
}
|
||||
}
|
||||
selfie::SelfieMaskSource::new(width, height)
|
||||
.map(|source| Box::new(source) as Box<dyn PersonMaskSource>)
|
||||
}
|
||||
|
||||
pub fn resize_mask_bilinear(
|
||||
src: &[u8],
|
||||
src_width: usize,
|
||||
src_height: usize,
|
||||
src_stride: usize,
|
||||
dst: &mut [u8],
|
||||
dst_width: usize,
|
||||
dst_height: usize,
|
||||
) {
|
||||
assert!(src_width >= 1);
|
||||
assert!(src_height >= 1);
|
||||
assert!(src_stride >= src_width);
|
||||
assert!(dst_width >= 1);
|
||||
assert!(dst_height >= 1);
|
||||
assert!(src.len() >= src_stride * (src_height - 1) + src_width);
|
||||
assert!(dst.len() >= dst_width * dst_height);
|
||||
|
||||
for y in 0..dst_height {
|
||||
let sy_fixed = if dst_height == 1 {
|
||||
0
|
||||
} else {
|
||||
y * (src_height - 1) * 256 / (dst_height - 1)
|
||||
};
|
||||
let sy = sy_fixed / 256;
|
||||
let fy = (sy_fixed % 256) as u32;
|
||||
let sy_next = (sy + 1).min(src_height - 1);
|
||||
for x in 0..dst_width {
|
||||
let sx_fixed = if dst_width == 1 {
|
||||
0
|
||||
} else {
|
||||
x * (src_width - 1) * 256 / (dst_width - 1)
|
||||
};
|
||||
let sx = sx_fixed / 256;
|
||||
let fx = (sx_fixed % 256) as u32;
|
||||
let sx_next = (sx + 1).min(src_width - 1);
|
||||
let top = u32::from(src[sy * src_stride + sx]) * (256 - fx)
|
||||
+ u32::from(src[sy * src_stride + sx_next]) * fx;
|
||||
let bottom = u32::from(src[sy_next * src_stride + sx]) * (256 - fx)
|
||||
+ u32::from(src[sy_next * src_stride + sx_next]) * fx;
|
||||
dst[y * dst_width + x] = ((top * (256 - fy) + bottom * fy) >> 16) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod selfie {
|
||||
use super::PersonMaskSource;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tract_onnx::prelude::*;
|
||||
|
||||
const MODEL_BYTES: &[u8] = include_bytes!("../models/selfie_segmenter_landscape.onnx");
|
||||
const MODEL_INPUT_WIDTH: usize = 256;
|
||||
const MODEL_INPUT_HEIGHT: usize = 144;
|
||||
const MODEL_INPUT_CHANNELS: usize = 3;
|
||||
const MODEL_INPUT_LEN: usize = MODEL_INPUT_WIDTH * MODEL_INPUT_HEIGHT * MODEL_INPUT_CHANNELS;
|
||||
const MODEL_CHROMA_WIDTH: usize = MODEL_INPUT_WIDTH / 2;
|
||||
const MODEL_CHROMA_HEIGHT: usize = MODEL_INPUT_HEIGHT / 2;
|
||||
const INFERENCE_FRAME_INTERVAL_FULL: u32 = 1;
|
||||
const INFERENCE_FRAME_INTERVAL_DOWNGRADED: u32 = 2;
|
||||
|
||||
type SelfiePlan = TypedRunnableModel;
|
||||
|
||||
fn shared_plan() -> Option<Arc<SelfiePlan>> {
|
||||
static PLAN: OnceLock<Option<Arc<SelfiePlan>>> = OnceLock::new();
|
||||
PLAN.get_or_init(|| match load_plan() {
|
||||
Ok(plan) => Some(plan),
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"webrtc-sender: selfie segmentation model failed to load; camera \
|
||||
background effects fall back to the portrait ellipse: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn load_plan() -> TractResult<Arc<SelfiePlan>> {
|
||||
let mut reader = std::io::Cursor::new(MODEL_BYTES);
|
||||
tract_onnx::onnx()
|
||||
.model_for_read(&mut reader)?
|
||||
.with_input_fact(
|
||||
0,
|
||||
f32::fact([
|
||||
1,
|
||||
MODEL_INPUT_HEIGHT,
|
||||
MODEL_INPUT_WIDTH,
|
||||
MODEL_INPUT_CHANNELS,
|
||||
])
|
||||
.into(),
|
||||
)?
|
||||
.into_optimized()?
|
||||
.into_runnable()
|
||||
}
|
||||
|
||||
pub struct SelfieMaskSource {
|
||||
width: u32,
|
||||
height: u32,
|
||||
plan: Arc<SelfiePlan>,
|
||||
luma_low: Vec<u8>,
|
||||
chroma_u_low: Vec<u8>,
|
||||
chroma_v_low: Vec<u8>,
|
||||
input_rgb: Vec<f32>,
|
||||
raw_mask_low: Vec<u8>,
|
||||
raw_mask_valid: bool,
|
||||
frame_counter: u32,
|
||||
inference_interval: u32,
|
||||
inference_error_logged: bool,
|
||||
governor: super::SegmentationQualityGovernor,
|
||||
}
|
||||
|
||||
impl SelfieMaskSource {
|
||||
pub fn new(width: u32, height: u32) -> Option<Self> {
|
||||
assert!(width >= 2);
|
||||
assert!(height >= 2);
|
||||
let plan = shared_plan()?;
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
plan,
|
||||
luma_low: vec![0; MODEL_INPUT_WIDTH * MODEL_INPUT_HEIGHT],
|
||||
chroma_u_low: vec![128; MODEL_CHROMA_WIDTH * MODEL_CHROMA_HEIGHT],
|
||||
chroma_v_low: vec![128; MODEL_CHROMA_WIDTH * MODEL_CHROMA_HEIGHT],
|
||||
input_rgb: vec![0.0; MODEL_INPUT_LEN],
|
||||
raw_mask_low: vec![0; MODEL_INPUT_WIDTH * MODEL_INPUT_HEIGHT],
|
||||
raw_mask_valid: false,
|
||||
frame_counter: 0,
|
||||
inference_interval: INFERENCE_FRAME_INTERVAL_FULL,
|
||||
inference_error_logged: false,
|
||||
governor: super::SegmentationQualityGovernor::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn fill_model_input(&mut self, frame: &crate::yuv::I420) {
|
||||
assert_eq!(frame.width, self.width);
|
||||
assert_eq!(frame.height, self.height);
|
||||
let width = self.width as usize;
|
||||
let height = self.height as usize;
|
||||
super::resize_mask_bilinear(
|
||||
&frame.y,
|
||||
width,
|
||||
height,
|
||||
width,
|
||||
&mut self.luma_low,
|
||||
MODEL_INPUT_WIDTH,
|
||||
MODEL_INPUT_HEIGHT,
|
||||
);
|
||||
super::resize_mask_bilinear(
|
||||
&frame.u,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width / 2,
|
||||
&mut self.chroma_u_low,
|
||||
MODEL_CHROMA_WIDTH,
|
||||
MODEL_CHROMA_HEIGHT,
|
||||
);
|
||||
super::resize_mask_bilinear(
|
||||
&frame.v,
|
||||
width / 2,
|
||||
height / 2,
|
||||
width / 2,
|
||||
&mut self.chroma_v_low,
|
||||
MODEL_CHROMA_WIDTH,
|
||||
MODEL_CHROMA_HEIGHT,
|
||||
);
|
||||
for y in 0..MODEL_INPUT_HEIGHT {
|
||||
let row = y * MODEL_INPUT_WIDTH;
|
||||
let chroma_row = (y / 2) * MODEL_CHROMA_WIDTH;
|
||||
for x in 0..MODEL_INPUT_WIDTH {
|
||||
let luma = i32::from(self.luma_low[row + x]) - 16;
|
||||
let cb = i32::from(self.chroma_u_low[chroma_row + x / 2]) - 128;
|
||||
let cr = i32::from(self.chroma_v_low[chroma_row + x / 2]) - 128;
|
||||
let r = ((298 * luma + 409 * cr + 128) >> 8).clamp(0, 255);
|
||||
let g = ((298 * luma - 100 * cb - 208 * cr + 128) >> 8).clamp(0, 255);
|
||||
let b = ((298 * luma + 516 * cb + 128) >> 8).clamp(0, 255);
|
||||
let offset = (row + x) * MODEL_INPUT_CHANNELS;
|
||||
self.input_rgb[offset] = r as f32 / 255.0;
|
||||
self.input_rgb[offset + 1] = g as f32 / 255.0;
|
||||
self.input_rgb[offset + 2] = b as f32 / 255.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_inference(&mut self, frame: &crate::yuv::I420) -> bool {
|
||||
self.fill_model_input(frame);
|
||||
let produced = self.run_model();
|
||||
if !produced && !self.inference_error_logged {
|
||||
self.inference_error_logged = true;
|
||||
eprintln!(
|
||||
"webrtc-sender: selfie segmentation inference failed; reusing the \
|
||||
previous person mask"
|
||||
);
|
||||
}
|
||||
produced
|
||||
}
|
||||
|
||||
fn run_model(&mut self) -> bool {
|
||||
assert_eq!(self.input_rgb.len(), MODEL_INPUT_LEN);
|
||||
let Ok(tensor) = Tensor::from_shape(
|
||||
&[
|
||||
1,
|
||||
MODEL_INPUT_HEIGHT,
|
||||
MODEL_INPUT_WIDTH,
|
||||
MODEL_INPUT_CHANNELS,
|
||||
],
|
||||
&self.input_rgb,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(result) = self.plan.run(tvec!(tensor.into())) else {
|
||||
return false;
|
||||
};
|
||||
let Some(output) = result.first() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(alphas) = output.to_plain_array_view::<f32>() else {
|
||||
return false;
|
||||
};
|
||||
if alphas.len() != self.raw_mask_low.len() {
|
||||
return false;
|
||||
}
|
||||
for (slot, alpha) in self.raw_mask_low.iter_mut().zip(alphas.iter()) {
|
||||
*slot = (alpha * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn record_inference_duration(&mut self, duration_ms: u64) {
|
||||
if self.governor.record_frame_duration_ms(duration_ms) {
|
||||
self.inference_interval = INFERENCE_FRAME_INTERVAL_DOWNGRADED;
|
||||
eprintln!(
|
||||
"webrtc-sender: selfie segmentation exceeded the {}ms frame budget for {} \
|
||||
consecutive frames; downgrading to inference every {} frames",
|
||||
super::SEGMENTATION_FRAME_BUDGET_MS,
|
||||
super::SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX,
|
||||
INFERENCE_FRAME_INTERVAL_DOWNGRADED
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PersonMaskSource for SelfieMaskSource {
|
||||
fn mask_into(&mut self, frame: &crate::yuv::I420, mask: &mut [u8]) -> bool {
|
||||
let width = self.width as usize;
|
||||
let height = self.height as usize;
|
||||
assert!(mask.len() >= width * height);
|
||||
if frame.width != self.width || frame.height != self.height {
|
||||
return false;
|
||||
}
|
||||
assert!(self.inference_interval >= 1);
|
||||
let due = self.frame_counter.is_multiple_of(self.inference_interval);
|
||||
self.frame_counter = self.frame_counter.wrapping_add(1);
|
||||
if due || !self.raw_mask_valid {
|
||||
let started = std::time::Instant::now();
|
||||
if self.run_inference(frame) {
|
||||
self.raw_mask_valid = true;
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
self.record_inference_duration(duration_ms);
|
||||
}
|
||||
}
|
||||
if !self.raw_mask_valid {
|
||||
return false;
|
||||
}
|
||||
super::resize_mask_bilinear(
|
||||
&self.raw_mask_low,
|
||||
MODEL_INPUT_WIDTH,
|
||||
MODEL_INPUT_HEIGHT,
|
||||
MODEL_INPUT_WIDTH,
|
||||
mask,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn synthetic_frame(width: u32, height: u32) -> crate::yuv::I420 {
|
||||
let mut frame = crate::yuv::I420::new(width, height).unwrap();
|
||||
for (index, value) in frame.y.iter_mut().enumerate() {
|
||||
*value = ((index * 31 + 17) % 220) as u8 + 16;
|
||||
}
|
||||
frame.u.fill(128);
|
||||
frame.v.fill(128);
|
||||
frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selfie_source_produces_full_range_mask_for_synthetic_frames() {
|
||||
let mut source = SelfieMaskSource::new(128, 96).expect("bundled model loads");
|
||||
let frame = synthetic_frame(128, 96);
|
||||
let mut mask = vec![0u8; 128 * 96];
|
||||
|
||||
assert!(source.mask_into(&frame, &mut mask));
|
||||
|
||||
assert_eq!(mask.len(), 128 * 96);
|
||||
assert!(source.raw_mask_valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selfie_source_rejects_mismatched_frame_dimensions() {
|
||||
let mut source = SelfieMaskSource::new(128, 96).expect("bundled model loads");
|
||||
let frame = synthetic_frame(64, 48);
|
||||
let mut mask = vec![0u8; 128 * 96];
|
||||
|
||||
assert!(!source.mask_into(&frame, &mut mask));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selfie_source_reuses_cached_mask_between_inference_frames() {
|
||||
let mut source = SelfieMaskSource::new(64, 48).expect("bundled model loads");
|
||||
source.inference_interval = INFERENCE_FRAME_INTERVAL_DOWNGRADED;
|
||||
let frame = synthetic_frame(64, 48);
|
||||
let mut first = vec![0u8; 64 * 48];
|
||||
let mut second = vec![0u8; 64 * 48];
|
||||
|
||||
assert!(source.mask_into(&frame, &mut first));
|
||||
assert!(source.mask_into(&frame, &mut second));
|
||||
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod vision {
|
||||
use super::PersonMaskSource;
|
||||
use core::ptr::NonNull;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_core_foundation::CFRetained;
|
||||
use objc2_core_video::{
|
||||
CVPixelBuffer, CVPixelBufferGetBaseAddress, CVPixelBufferGetBaseAddressOfPlane,
|
||||
CVPixelBufferGetBytesPerRow, CVPixelBufferGetBytesPerRowOfPlane, CVPixelBufferGetHeight,
|
||||
CVPixelBufferGetPixelFormatType, CVPixelBufferGetWidth, CVPixelBufferLockBaseAddress,
|
||||
CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress,
|
||||
};
|
||||
use objc2_vision::{
|
||||
VNGeneratePersonSegmentationRequest, VNGeneratePersonSegmentationRequestQualityLevel,
|
||||
VNRequest, VNSequenceRequestHandler,
|
||||
};
|
||||
|
||||
const PIXEL_FORMAT_NV12_FULL_RANGE: u32 = u32::from_be_bytes(*b"420f");
|
||||
const PIXEL_FORMAT_ONE_COMPONENT_8: u32 = u32::from_be_bytes(*b"L008");
|
||||
const MASK_PIXELS_MAX: usize = 8192 * 8192;
|
||||
|
||||
pub struct VisionPersonMaskSource {
|
||||
width: u32,
|
||||
height: u32,
|
||||
pixel_buffer: CFRetained<CVPixelBuffer>,
|
||||
request: Retained<VNGeneratePersonSegmentationRequest>,
|
||||
requests: Retained<objc2_foundation::NSArray<VNRequest>>,
|
||||
handler: Retained<VNSequenceRequestHandler>,
|
||||
quality_governor: super::SegmentationQualityGovernor,
|
||||
}
|
||||
|
||||
impl VisionPersonMaskSource {
|
||||
pub fn new(width: u32, height: u32) -> Option<Self> {
|
||||
assert!(width >= 2);
|
||||
assert!(height >= 2);
|
||||
assert!(width.is_multiple_of(2));
|
||||
assert!(height.is_multiple_of(2));
|
||||
let mut pixel_buffer_out: *mut CVPixelBuffer = core::ptr::null_mut();
|
||||
let status = unsafe {
|
||||
objc2_core_video::CVPixelBufferCreate(
|
||||
None,
|
||||
width as usize,
|
||||
height as usize,
|
||||
PIXEL_FORMAT_NV12_FULL_RANGE,
|
||||
None,
|
||||
NonNull::new(&mut pixel_buffer_out)?,
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
return None;
|
||||
}
|
||||
let pixel_buffer = unsafe { CFRetained::from_raw(NonNull::new(pixel_buffer_out)?) };
|
||||
let request = unsafe { VNGeneratePersonSegmentationRequest::new() };
|
||||
unsafe {
|
||||
request.setQualityLevel(VNGeneratePersonSegmentationRequestQualityLevel::Balanced);
|
||||
request.setOutputPixelFormat(PIXEL_FORMAT_ONE_COMPONENT_8);
|
||||
}
|
||||
let request_as_base: Retained<VNRequest> =
|
||||
Retained::into_super(Retained::into_super(Retained::into_super(request.clone())));
|
||||
let requests = objc2_foundation::NSArray::from_retained_slice(&[request_as_base]);
|
||||
assert_eq!(requests.len(), 1);
|
||||
let handler = unsafe { VNSequenceRequestHandler::new() };
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
pixel_buffer,
|
||||
request,
|
||||
requests,
|
||||
handler,
|
||||
quality_governor: super::SegmentationQualityGovernor::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn downgrade_to_fast_quality(&self) {
|
||||
unsafe {
|
||||
self.request
|
||||
.setQualityLevel(VNGeneratePersonSegmentationRequestQualityLevel::Fast);
|
||||
}
|
||||
eprintln!(
|
||||
"webrtc-sender: person segmentation exceeded the {}ms frame budget for {} \
|
||||
consecutive frames; downgrading Vision quality from balanced to fast",
|
||||
super::SEGMENTATION_FRAME_BUDGET_MS,
|
||||
super::SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX
|
||||
);
|
||||
}
|
||||
|
||||
fn fill_pixel_buffer(&self, frame: &crate::yuv::I420) -> bool {
|
||||
assert_eq!(frame.width, self.width);
|
||||
assert_eq!(frame.height, self.height);
|
||||
let width = self.width as usize;
|
||||
let height = self.height as usize;
|
||||
let lock_flags = CVPixelBufferLockFlags(0);
|
||||
let lock_status =
|
||||
unsafe { CVPixelBufferLockBaseAddress(&self.pixel_buffer, lock_flags) };
|
||||
if lock_status != 0 {
|
||||
return false;
|
||||
}
|
||||
let y_base = CVPixelBufferGetBaseAddressOfPlane(&self.pixel_buffer, 0);
|
||||
let y_stride = CVPixelBufferGetBytesPerRowOfPlane(&self.pixel_buffer, 0);
|
||||
let uv_base = CVPixelBufferGetBaseAddressOfPlane(&self.pixel_buffer, 1);
|
||||
let uv_stride = CVPixelBufferGetBytesPerRowOfPlane(&self.pixel_buffer, 1);
|
||||
if y_base.is_null() || uv_base.is_null() || y_stride < width || uv_stride < width {
|
||||
let _ = unsafe { CVPixelBufferUnlockBaseAddress(&self.pixel_buffer, lock_flags) };
|
||||
return false;
|
||||
}
|
||||
let chroma_width = width / 2;
|
||||
let chroma_height = height / 2;
|
||||
unsafe {
|
||||
let y_base = y_base as *mut u8;
|
||||
for row in 0..height {
|
||||
let src = &frame.y[row * width..row * width + width];
|
||||
core::ptr::copy_nonoverlapping(src.as_ptr(), y_base.add(row * y_stride), width);
|
||||
}
|
||||
let uv_base = uv_base as *mut u8;
|
||||
for row in 0..chroma_height {
|
||||
let dst_row = uv_base.add(row * uv_stride);
|
||||
for col in 0..chroma_width {
|
||||
let chroma_index = row * chroma_width + col;
|
||||
dst_row.add(col * 2).write(frame.u[chroma_index]);
|
||||
dst_row.add(col * 2 + 1).write(frame.v[chroma_index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let unlock_status =
|
||||
unsafe { CVPixelBufferUnlockBaseAddress(&self.pixel_buffer, lock_flags) };
|
||||
unlock_status == 0
|
||||
}
|
||||
|
||||
fn copy_observation_mask(
|
||||
mask_buffer: &CVPixelBuffer,
|
||||
mask: &mut [u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> bool {
|
||||
if CVPixelBufferGetPixelFormatType(mask_buffer) != PIXEL_FORMAT_ONE_COMPONENT_8 {
|
||||
return false;
|
||||
}
|
||||
let lock_flags = CVPixelBufferLockFlags::ReadOnly;
|
||||
if unsafe { CVPixelBufferLockBaseAddress(mask_buffer, lock_flags) } != 0 {
|
||||
return false;
|
||||
}
|
||||
let src_width = CVPixelBufferGetWidth(mask_buffer);
|
||||
let src_height = CVPixelBufferGetHeight(mask_buffer);
|
||||
let src_stride = CVPixelBufferGetBytesPerRow(mask_buffer);
|
||||
let base = CVPixelBufferGetBaseAddress(mask_buffer);
|
||||
let valid = !base.is_null()
|
||||
&& src_width >= 1
|
||||
&& src_height >= 1
|
||||
&& src_stride >= src_width
|
||||
&& src_width * src_height <= MASK_PIXELS_MAX;
|
||||
if valid {
|
||||
let src = unsafe {
|
||||
core::slice::from_raw_parts(
|
||||
base as *const u8,
|
||||
src_stride * (src_height - 1) + src_width,
|
||||
)
|
||||
};
|
||||
super::resize_mask_bilinear(
|
||||
src, src_width, src_height, src_stride, mask, width, height,
|
||||
);
|
||||
}
|
||||
let _ = unsafe { CVPixelBufferUnlockBaseAddress(mask_buffer, lock_flags) };
|
||||
valid
|
||||
}
|
||||
}
|
||||
|
||||
impl PersonMaskSource for VisionPersonMaskSource {
|
||||
fn mask_into(&mut self, frame: &crate::yuv::I420, mask: &mut [u8]) -> bool {
|
||||
let started = std::time::Instant::now();
|
||||
let produced = self.mask_into_timed(frame, mask);
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
if self.quality_governor.record_frame_duration_ms(duration_ms) {
|
||||
self.downgrade_to_fast_quality();
|
||||
}
|
||||
produced
|
||||
}
|
||||
}
|
||||
|
||||
impl VisionPersonMaskSource {
|
||||
fn mask_into_timed(&mut self, frame: &crate::yuv::I420, mask: &mut [u8]) -> bool {
|
||||
let width = self.width as usize;
|
||||
let height = self.height as usize;
|
||||
assert!(mask.len() >= width * height);
|
||||
if frame.width != self.width || frame.height != self.height {
|
||||
return false;
|
||||
}
|
||||
if !self.fill_pixel_buffer(frame) {
|
||||
return false;
|
||||
}
|
||||
let performed = unsafe {
|
||||
self.handler
|
||||
.performRequests_onCVPixelBuffer_error(&self.requests, &self.pixel_buffer)
|
||||
};
|
||||
if performed.is_err() {
|
||||
return false;
|
||||
}
|
||||
let Some(results) = (unsafe { self.request.results() }) else {
|
||||
return false;
|
||||
};
|
||||
let Some(observation) = results.firstObject() else {
|
||||
return false;
|
||||
};
|
||||
let mask_buffer = unsafe { observation.pixelBuffer() };
|
||||
Self::copy_observation_mask(&mask_buffer, mask, width, height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resize_identity_returns_same_values() {
|
||||
let src = vec![0u8, 64, 128, 255];
|
||||
let mut dst = vec![0u8; 4];
|
||||
|
||||
resize_mask_bilinear(&src, 2, 2, 2, &mut dst, 2, 2);
|
||||
|
||||
assert_eq!(dst, src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_upscales_with_interpolated_midpoints() {
|
||||
let src = vec![0u8, 255, 0, 255];
|
||||
let mut dst = vec![0u8; 9];
|
||||
|
||||
resize_mask_bilinear(&src, 2, 2, 2, &mut dst, 3, 3);
|
||||
|
||||
assert_eq!(dst[0], 0);
|
||||
assert_eq!(dst[2], 255);
|
||||
assert!(dst[1] > 100);
|
||||
assert!(dst[1] < 156);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_honours_source_stride_padding() {
|
||||
let src = vec![10u8, 20, 99, 99, 30, 40, 99, 99];
|
||||
let mut dst = vec![0u8; 4];
|
||||
|
||||
resize_mask_bilinear(&src, 2, 2, 4, &mut dst, 2, 2);
|
||||
|
||||
assert_eq!(dst, vec![10, 20, 30, 40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_collapses_to_single_pixel_average_free() {
|
||||
let src = vec![200u8; 16];
|
||||
let mut dst = vec![0u8; 1];
|
||||
|
||||
resize_mask_bilinear(&src, 4, 4, 4, &mut dst, 1, 1);
|
||||
|
||||
assert_eq!(dst, vec![200]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_constants_span_full_alpha_range() {
|
||||
assert_eq!(PERSON_MASK_BACKGROUND, 0);
|
||||
assert_eq!(PERSON_MASK_PERSON, 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segmentation_governor_downgrades_after_consecutive_slow_frames() {
|
||||
let mut governor = SegmentationQualityGovernor::new();
|
||||
|
||||
for _ in 1..SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX {
|
||||
assert!(!governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 1));
|
||||
}
|
||||
|
||||
assert!(governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segmentation_governor_resets_count_after_a_frame_within_budget() {
|
||||
let mut governor = SegmentationQualityGovernor::new();
|
||||
|
||||
for _ in 1..SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX {
|
||||
assert!(!governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 1));
|
||||
}
|
||||
assert!(!governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS));
|
||||
for _ in 1..SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX {
|
||||
assert!(!governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 1));
|
||||
}
|
||||
|
||||
assert!(governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segmentation_governor_downgrades_only_once() {
|
||||
let mut governor = SegmentationQualityGovernor::new();
|
||||
for _ in 0..SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX - 1 {
|
||||
assert!(!governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 1));
|
||||
}
|
||||
assert!(governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 1));
|
||||
|
||||
for _ in 0..SEGMENTATION_SLOW_FRAMES_CONSECUTIVE_MAX * 2 {
|
||||
assert!(!governor.record_frame_duration_ms(SEGMENTATION_FRAME_BUDGET_MS + 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub const DEFAULT_AUDIO_BUFFER_TARGET_MS: u32 = 200;
|
||||
pub const DEFAULT_AUDIO_BUFFER_MAX_MS: u32 = 750;
|
||||
pub const DEFAULT_MIN_VIDEO_FPS: f64 = 15.0;
|
||||
|
||||
const PRESSURE_WINDOW_MS: u64 = 5_000;
|
||||
const RECOVERY_WINDOW_COUNT: u32 = 12;
|
||||
const AUDIO_REBUFFER_GAP_MS: u64 = 120;
|
||||
const AUDIO_STABLE_GAP_MS: u64 = 60;
|
||||
const AUDIO_BUFFER_STEP_MS: u32 = 100;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SendHealthSnapshot {
|
||||
pub outgoing_video_queue_depth: u64,
|
||||
pub outgoing_video_queue_capacity: u64,
|
||||
pub outgoing_video_max_queue_depth: u64,
|
||||
pub outgoing_video_frames_produced: u64,
|
||||
pub outgoing_video_frames_accepted: u64,
|
||||
pub outgoing_video_frames_dropped: u64,
|
||||
pub outgoing_video_frames_coalesced: u64,
|
||||
pub outgoing_video_frames_captured: u64,
|
||||
pub outgoing_video_capture_failures: u64,
|
||||
pub outgoing_video_effective_fps: f64,
|
||||
pub outgoing_video_target_fps: f64,
|
||||
pub outgoing_video_pacing_target_fps: f64,
|
||||
pub outgoing_video_max_queue_age_ms: u64,
|
||||
pub outgoing_video_max_push_latency_ms: u64,
|
||||
pub outgoing_video_pacing_mode: String,
|
||||
pub outgoing_video_bus_active: bool,
|
||||
pub outgoing_audio_buffer_target_ms: u32,
|
||||
pub outgoing_audio_buffer_max_ms: u32,
|
||||
pub outgoing_audio_underruns: u64,
|
||||
pub outgoing_audio_rebuffers: u64,
|
||||
pub outgoing_audio_max_frame_gap_ms: u64,
|
||||
pub adaptive_send_tier: String,
|
||||
pub adaptive_send_reason: String,
|
||||
}
|
||||
|
||||
impl SendHealthSnapshot {
|
||||
pub fn idle(audio: &AdaptiveAudioStats) -> Self {
|
||||
Self {
|
||||
outgoing_video_queue_depth: 0,
|
||||
outgoing_video_queue_capacity: 0,
|
||||
outgoing_video_max_queue_depth: 0,
|
||||
outgoing_video_frames_produced: 0,
|
||||
outgoing_video_frames_accepted: 0,
|
||||
outgoing_video_frames_dropped: 0,
|
||||
outgoing_video_frames_coalesced: 0,
|
||||
outgoing_video_frames_captured: 0,
|
||||
outgoing_video_capture_failures: 0,
|
||||
outgoing_video_effective_fps: 0.0,
|
||||
outgoing_video_target_fps: 0.0,
|
||||
outgoing_video_pacing_target_fps: 0.0,
|
||||
outgoing_video_max_queue_age_ms: 0,
|
||||
outgoing_video_max_push_latency_ms: 0,
|
||||
outgoing_video_pacing_mode: "idle".to_string(),
|
||||
outgoing_video_bus_active: false,
|
||||
outgoing_audio_buffer_target_ms: audio.target_buffer_ms(),
|
||||
outgoing_audio_buffer_max_ms: audio.max_buffer_ms(),
|
||||
outgoing_audio_underruns: audio.underruns.load(Ordering::Relaxed),
|
||||
outgoing_audio_rebuffers: audio.rebuffers.load(Ordering::Relaxed),
|
||||
outgoing_audio_max_frame_gap_ms: audio.max_frame_gap_ms.load(Ordering::Relaxed),
|
||||
adaptive_send_tier: "idle".to_string(),
|
||||
adaptive_send_reason: "notPublishing".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdaptiveVideoController {
|
||||
requested_fps: f64,
|
||||
min_fps: f64,
|
||||
adaptive: bool,
|
||||
state: Mutex<AdaptiveVideoState>,
|
||||
}
|
||||
|
||||
struct AdaptiveVideoState {
|
||||
current_fps: f64,
|
||||
tier: String,
|
||||
reason: String,
|
||||
window_started_ms: u64,
|
||||
window_produced: u64,
|
||||
window_coalesced: u64,
|
||||
window_dropped: u64,
|
||||
window_max_queue_age_ms: u64,
|
||||
window_max_push_latency_ms: u64,
|
||||
window_egress_fps_sum: f64,
|
||||
window_egress_fps_samples: u32,
|
||||
stable_windows: u32,
|
||||
}
|
||||
|
||||
impl AdaptiveVideoController {
|
||||
pub fn new(requested_fps: f64, min_fps: f64, adaptive: bool, now_ms: u64) -> Self {
|
||||
let requested_fps = sanitize_fps(requested_fps, 30.0);
|
||||
let min_fps = sanitize_fps(min_fps, DEFAULT_MIN_VIDEO_FPS).min(requested_fps);
|
||||
Self {
|
||||
requested_fps,
|
||||
min_fps,
|
||||
adaptive,
|
||||
state: Mutex::new(AdaptiveVideoState {
|
||||
current_fps: requested_fps,
|
||||
tier: "full".to_string(),
|
||||
reason: "stable".to_string(),
|
||||
window_started_ms: now_ms,
|
||||
window_produced: 0,
|
||||
window_coalesced: 0,
|
||||
window_dropped: 0,
|
||||
window_max_queue_age_ms: 0,
|
||||
window_max_push_latency_ms: 0,
|
||||
window_egress_fps_sum: 0.0,
|
||||
window_egress_fps_samples: 0,
|
||||
stable_windows: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_fps(&self) -> f64 {
|
||||
self.state.lock().current_fps
|
||||
}
|
||||
|
||||
pub fn tier_and_reason(&self) -> (String, String) {
|
||||
let state = self.state.lock();
|
||||
(state.tier.clone(), state.reason.clone())
|
||||
}
|
||||
|
||||
pub fn record_enqueue(&self, now_ms: u64, coalesced: bool) {
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_produced += 1;
|
||||
if coalesced {
|
||||
state.window_coalesced += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn record_drop(&self, now_ms: u64) {
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_dropped += 1;
|
||||
}
|
||||
|
||||
pub fn record_capture(&self, now_ms: u64, queue_age_ms: u64, push_latency_ms: u64) {
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_max_queue_age_ms = state.window_max_queue_age_ms.max(queue_age_ms);
|
||||
state.window_max_push_latency_ms = state.window_max_push_latency_ms.max(push_latency_ms);
|
||||
}
|
||||
|
||||
pub fn record_egress_fps(&self, now_ms: u64, fps: f64) {
|
||||
if !fps.is_finite() || fps < 0.0 {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.lock();
|
||||
self.rotate_window(&mut state, now_ms);
|
||||
state.window_egress_fps_sum += fps;
|
||||
state.window_egress_fps_samples += 1;
|
||||
}
|
||||
|
||||
fn rotate_window(&self, state: &mut AdaptiveVideoState, now_ms: u64) {
|
||||
if now_ms.saturating_sub(state.window_started_ms) < PRESSURE_WINDOW_MS {
|
||||
return;
|
||||
}
|
||||
self.apply_window(state);
|
||||
state.window_started_ms = now_ms;
|
||||
state.window_produced = 0;
|
||||
state.window_coalesced = 0;
|
||||
state.window_dropped = 0;
|
||||
state.window_max_queue_age_ms = 0;
|
||||
state.window_max_push_latency_ms = 0;
|
||||
state.window_egress_fps_sum = 0.0;
|
||||
state.window_egress_fps_samples = 0;
|
||||
}
|
||||
|
||||
fn apply_window(&self, state: &mut AdaptiveVideoState) {
|
||||
if !self.adaptive {
|
||||
state.current_fps = self.requested_fps;
|
||||
state.tier = "full".to_string();
|
||||
state.reason = "adaptiveDisabled".to_string();
|
||||
return;
|
||||
}
|
||||
|
||||
let frame_interval_ms = (1000.0 / state.current_fps.max(1.0)).ceil() as u64;
|
||||
let latency_pressure = state.window_max_queue_age_ms > frame_interval_ms * 2
|
||||
|| state.window_max_push_latency_ms > frame_interval_ms * 2;
|
||||
let drop_ratio = state.window_dropped as f64 / state.window_produced.max(1) as f64;
|
||||
let encoder_drop_pressure = state.window_produced >= 10 && drop_ratio > 0.05;
|
||||
let average_egress_fps = if state.window_egress_fps_samples == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(state.window_egress_fps_sum / state.window_egress_fps_samples as f64)
|
||||
};
|
||||
let egress_pressure = average_egress_fps.is_some_and(|fps| {
|
||||
state.window_egress_fps_samples >= 2
|
||||
&& state.window_produced >= 10
|
||||
&& fps < state.current_fps * 0.75
|
||||
});
|
||||
let pressure = latency_pressure || encoder_drop_pressure || egress_pressure;
|
||||
|
||||
if pressure {
|
||||
let next = if state.current_fps > 30.0 {
|
||||
30.0
|
||||
} else if state.current_fps > self.min_fps {
|
||||
self.min_fps
|
||||
} else {
|
||||
state.current_fps
|
||||
};
|
||||
if next < state.current_fps {
|
||||
state.current_fps = next;
|
||||
state.tier = tier_for_fps(self.requested_fps, state.current_fps);
|
||||
}
|
||||
state.reason = if latency_pressure {
|
||||
"sendLatencyPressure".to_string()
|
||||
} else if encoder_drop_pressure {
|
||||
"encoderDropPressure".to_string()
|
||||
} else {
|
||||
"encoderEgressPressure".to_string()
|
||||
};
|
||||
state.stable_windows = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
state.stable_windows += 1;
|
||||
if state.current_fps >= self.requested_fps {
|
||||
state.reason = "stable".to_string();
|
||||
}
|
||||
if state.stable_windows >= RECOVERY_WINDOW_COUNT && state.current_fps < self.requested_fps {
|
||||
state.current_fps = (state.current_fps * 2.0).min(self.requested_fps);
|
||||
state.tier = tier_for_fps(self.requested_fps, state.current_fps);
|
||||
state.stable_windows = 0;
|
||||
if state.current_fps >= self.requested_fps {
|
||||
state.reason = "stable".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdaptiveVideoStats {
|
||||
produced: AtomicU64,
|
||||
accepted: AtomicU64,
|
||||
dropped: AtomicU64,
|
||||
coalesced: AtomicU64,
|
||||
captured: AtomicU64,
|
||||
capture_failures: AtomicU64,
|
||||
queue_depth: AtomicU64,
|
||||
max_queue_depth: AtomicU64,
|
||||
max_queue_age_ms: AtomicU64,
|
||||
max_push_latency_ms: AtomicU64,
|
||||
first_capture_ms: AtomicU64,
|
||||
last_capture_ms: AtomicU64,
|
||||
controller: AdaptiveVideoController,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoTelemetryExtras {
|
||||
pub pacing_mode: String,
|
||||
pub pacing_target_fps: f64,
|
||||
pub queue_capacity: u64,
|
||||
pub bus_active: bool,
|
||||
}
|
||||
|
||||
impl Default for VideoTelemetryExtras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pacing_mode: "unknown".to_string(),
|
||||
pacing_target_fps: 0.0,
|
||||
queue_capacity: 0,
|
||||
bus_active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AdaptiveVideoStats {
|
||||
pub fn new(requested_fps: f64, min_fps: f64, adaptive: bool, now_ms: u64) -> Self {
|
||||
Self {
|
||||
produced: AtomicU64::new(0),
|
||||
accepted: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
coalesced: AtomicU64::new(0),
|
||||
captured: AtomicU64::new(0),
|
||||
capture_failures: AtomicU64::new(0),
|
||||
queue_depth: AtomicU64::new(0),
|
||||
max_queue_depth: AtomicU64::new(0),
|
||||
max_queue_age_ms: AtomicU64::new(0),
|
||||
max_push_latency_ms: AtomicU64::new(0),
|
||||
first_capture_ms: AtomicU64::new(0),
|
||||
last_capture_ms: AtomicU64::new(0),
|
||||
controller: AdaptiveVideoController::new(requested_fps, min_fps, adaptive, now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn record_enqueue(&self, now_ms: u64, replaced_pending: bool) {
|
||||
self.record_enqueue_with_depth(now_ms, replaced_pending, 1);
|
||||
}
|
||||
|
||||
pub fn record_enqueue_with_depth(&self, now_ms: u64, replaced_pending: bool, queue_depth: u64) {
|
||||
self.produced.fetch_add(1, Ordering::Relaxed);
|
||||
self.accepted.fetch_add(1, Ordering::Relaxed);
|
||||
self.queue_depth.store(queue_depth, Ordering::Relaxed);
|
||||
update_max(&self.max_queue_depth, queue_depth);
|
||||
if replaced_pending {
|
||||
self.coalesced.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.controller.record_enqueue(now_ms, replaced_pending);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn record_drop(&self, now_ms: u64) {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
self.controller.record_drop(now_ms);
|
||||
}
|
||||
|
||||
pub fn record_reject(&self) {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_capture(&self, now_ms: u64, queue_age_ms: u64, push_latency_ms: u64) {
|
||||
update_min_nonzero(&self.first_capture_ms, now_ms);
|
||||
update_max(&self.last_capture_ms, now_ms);
|
||||
self.captured.fetch_add(1, Ordering::Relaxed);
|
||||
self.queue_depth.store(0, Ordering::Relaxed);
|
||||
update_max(&self.max_queue_age_ms, queue_age_ms);
|
||||
update_max(&self.max_push_latency_ms, push_latency_ms);
|
||||
self.controller
|
||||
.record_capture(now_ms, queue_age_ms, push_latency_ms);
|
||||
}
|
||||
|
||||
pub fn record_egress_fps(&self, now_ms: u64, fps: f64) {
|
||||
self.controller.record_egress_fps(now_ms, fps);
|
||||
}
|
||||
|
||||
pub fn record_capture_failure(&self) {
|
||||
self.capture_failures.fetch_add(1, Ordering::Relaxed);
|
||||
self.queue_depth.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_queue_cleared(&self) {
|
||||
self.queue_depth.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn current_fps(&self) -> f64 {
|
||||
self.controller.current_fps()
|
||||
}
|
||||
|
||||
fn effective_fps(&self) -> f64 {
|
||||
let first = self.first_capture_ms.load(Ordering::Relaxed);
|
||||
let last = self.last_capture_ms.load(Ordering::Relaxed);
|
||||
let captured = self.captured.load(Ordering::Relaxed);
|
||||
if first == 0 || last <= first || captured <= 1 {
|
||||
return 0.0;
|
||||
}
|
||||
let elapsed_s = (last - first) as f64 / 1000.0;
|
||||
((captured - 1) as f64 / elapsed_s * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
pub fn snapshot(
|
||||
&self,
|
||||
audio: &AdaptiveAudioStats,
|
||||
extras: VideoTelemetryExtras,
|
||||
) -> SendHealthSnapshot {
|
||||
let (tier, reason) = self.controller.tier_and_reason();
|
||||
SendHealthSnapshot {
|
||||
outgoing_video_queue_depth: self.queue_depth.load(Ordering::Relaxed),
|
||||
outgoing_video_queue_capacity: extras.queue_capacity,
|
||||
outgoing_video_max_queue_depth: self.max_queue_depth.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_produced: self.produced.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_accepted: self.accepted.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_dropped: self.dropped.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_coalesced: self.coalesced.load(Ordering::Relaxed),
|
||||
outgoing_video_frames_captured: self.captured.load(Ordering::Relaxed),
|
||||
outgoing_video_capture_failures: self.capture_failures.load(Ordering::Relaxed),
|
||||
outgoing_video_effective_fps: self.effective_fps(),
|
||||
outgoing_video_target_fps: (self.current_fps() * 100.0).round() / 100.0,
|
||||
outgoing_video_pacing_target_fps: (extras.pacing_target_fps * 100.0).round() / 100.0,
|
||||
outgoing_video_max_queue_age_ms: self.max_queue_age_ms.load(Ordering::Relaxed),
|
||||
outgoing_video_max_push_latency_ms: self.max_push_latency_ms.load(Ordering::Relaxed),
|
||||
outgoing_video_pacing_mode: extras.pacing_mode,
|
||||
outgoing_video_bus_active: extras.bus_active,
|
||||
outgoing_audio_buffer_target_ms: audio.target_buffer_ms(),
|
||||
outgoing_audio_buffer_max_ms: audio.max_buffer_ms(),
|
||||
outgoing_audio_underruns: audio.underruns.load(Ordering::Relaxed),
|
||||
outgoing_audio_rebuffers: audio.rebuffers.load(Ordering::Relaxed),
|
||||
outgoing_audio_max_frame_gap_ms: audio.max_frame_gap_ms.load(Ordering::Relaxed),
|
||||
adaptive_send_tier: tier,
|
||||
adaptive_send_reason: reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AdaptiveAudioStats {
|
||||
max_buffer_ms: AtomicU64,
|
||||
target_buffer_ms: AtomicU64,
|
||||
underruns: AtomicU64,
|
||||
rebuffers: AtomicU64,
|
||||
max_frame_gap_ms: AtomicU64,
|
||||
last_push_ms: AtomicU64,
|
||||
stable_started_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl AdaptiveAudioStats {
|
||||
pub fn new(max_buffer_ms: u32, now_ms: u64) -> Self {
|
||||
let max_buffer_ms = clamp_audio_buffer_ms(max_buffer_ms);
|
||||
Self {
|
||||
max_buffer_ms: AtomicU64::new(max_buffer_ms as u64),
|
||||
target_buffer_ms: AtomicU64::new(
|
||||
DEFAULT_AUDIO_BUFFER_TARGET_MS.min(max_buffer_ms) as u64
|
||||
),
|
||||
underruns: AtomicU64::new(0),
|
||||
rebuffers: AtomicU64::new(0),
|
||||
max_frame_gap_ms: AtomicU64::new(0),
|
||||
last_push_ms: AtomicU64::new(0),
|
||||
stable_started_ms: AtomicU64::new(now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&self, max_buffer_ms: u32, now_ms: u64) {
|
||||
let max_buffer_ms = clamp_audio_buffer_ms(max_buffer_ms);
|
||||
self.max_buffer_ms
|
||||
.store(max_buffer_ms as u64, Ordering::Relaxed);
|
||||
self.target_buffer_ms.store(
|
||||
DEFAULT_AUDIO_BUFFER_TARGET_MS.min(max_buffer_ms) as u64,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
self.underruns.store(0, Ordering::Relaxed);
|
||||
self.rebuffers.store(0, Ordering::Relaxed);
|
||||
self.max_frame_gap_ms.store(0, Ordering::Relaxed);
|
||||
self.last_push_ms.store(0, Ordering::Relaxed);
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn record_push(&self, now_ms: u64) {
|
||||
let Some(previous) = advance_monotonic(&self.last_push_ms, now_ms) else {
|
||||
return;
|
||||
};
|
||||
if previous == 0 {
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let gap = now_ms - previous;
|
||||
update_max(&self.max_frame_gap_ms, gap);
|
||||
if gap > AUDIO_REBUFFER_GAP_MS {
|
||||
self.rebuffers.fetch_add(1, Ordering::Relaxed);
|
||||
if gap > self.target_buffer_ms() as u64 {
|
||||
self.underruns.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let next = (self.target_buffer_ms() + AUDIO_BUFFER_STEP_MS).min(self.max_buffer_ms());
|
||||
self.target_buffer_ms.store(next as u64, Ordering::Relaxed);
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
if gap <= AUDIO_STABLE_GAP_MS {
|
||||
let stable_started = self.stable_started_ms.load(Ordering::Relaxed);
|
||||
if now_ms.saturating_sub(stable_started) >= 30_000 {
|
||||
let current = self.target_buffer_ms();
|
||||
let next = current
|
||||
.saturating_sub(AUDIO_BUFFER_STEP_MS)
|
||||
.max(DEFAULT_AUDIO_BUFFER_TARGET_MS.min(self.max_buffer_ms()));
|
||||
self.target_buffer_ms.store(next as u64, Ordering::Relaxed);
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
} else {
|
||||
self.stable_started_ms.store(now_ms, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_buffer_ms(&self) -> u32 {
|
||||
self.max_buffer_ms.load(Ordering::Relaxed) as u32
|
||||
}
|
||||
|
||||
pub fn target_buffer_ms(&self) -> u32 {
|
||||
self.target_buffer_ms.load(Ordering::Relaxed) as u32
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clamp_audio_buffer_ms(value: u32) -> u32 {
|
||||
let clamped = value.clamp(DEFAULT_AUDIO_BUFFER_TARGET_MS, DEFAULT_AUDIO_BUFFER_MAX_MS);
|
||||
clamped - (clamped % 10)
|
||||
}
|
||||
|
||||
fn sanitize_fps(value: f64, fallback: f64) -> f64 {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
value
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_for_fps(requested_fps: f64, current_fps: f64) -> String {
|
||||
if current_fps >= requested_fps {
|
||||
"full".to_string()
|
||||
} else if current_fps >= 30.0 {
|
||||
"fps30".to_string()
|
||||
} else {
|
||||
"fps15".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn update_max(slot: &AtomicU64, value: u64) {
|
||||
let mut current = slot.load(Ordering::Relaxed);
|
||||
while value > current {
|
||||
match slot.compare_exchange(current, value, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => break,
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_min_nonzero(slot: &AtomicU64, value: u64) {
|
||||
if value == 0 {
|
||||
return;
|
||||
}
|
||||
let mut current = slot.load(Ordering::Relaxed);
|
||||
loop {
|
||||
if current != 0 && current <= value {
|
||||
return;
|
||||
}
|
||||
match slot.compare_exchange(current, value, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return,
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_monotonic(slot: &AtomicU64, value: u64) -> Option<u64> {
|
||||
let mut current = slot.load(Ordering::Relaxed);
|
||||
loop {
|
||||
if current != 0 && value < current {
|
||||
return None;
|
||||
}
|
||||
if value == current {
|
||||
return Some(current);
|
||||
}
|
||||
match slot.compare_exchange(current, value, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => return Some(current),
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn video_controller_ignores_pure_coalescing_jitter() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
for _ in 0..200 {
|
||||
controller.record_enqueue(1_000, true);
|
||||
}
|
||||
controller.record_enqueue(5_001, false);
|
||||
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "full");
|
||||
assert_eq!(controller.tier_and_reason().1, "stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_ignores_sustained_coalescing_jitter_across_windows() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
for _ in 0..200 {
|
||||
controller.record_enqueue(1_000, true);
|
||||
}
|
||||
controller.record_enqueue(5_001, false);
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
|
||||
for _ in 0..200 {
|
||||
controller.record_enqueue(6_000, true);
|
||||
}
|
||||
controller.record_enqueue(10_002, false);
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_degrades_on_encoder_drop_pressure() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
for _ in 0..100 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
for _ in 0..20 {
|
||||
controller.record_drop(1_000);
|
||||
}
|
||||
controller.record_enqueue(5_001, false);
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderDropPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_continues_degrading_under_sustained_latency_pressure() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
controller.record_capture(6_000, 80, 1);
|
||||
controller.record_capture(10_002, 1, 1);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "fps15");
|
||||
assert_eq!(controller.tier_and_reason().1, "sendLatencyPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_degrades_on_latency_pressure_without_coalescing() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 5);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "sendLatencyPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_degrades_on_encoder_egress_pressure() {
|
||||
let controller = AdaptiveVideoController::new(30.0, 15.0, true, 0);
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(1_000, 7.0);
|
||||
controller.record_egress_fps(2_000, 8.0);
|
||||
controller.record_egress_fps(4_000, 7.0);
|
||||
|
||||
controller.record_egress_fps(5_001, 7.0);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "fps15");
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderEgressPressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_preserves_pressure_reason_while_degraded_but_stable() {
|
||||
let controller = AdaptiveVideoController::new(30.0, 15.0, true, 0);
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(1_000, 7.0);
|
||||
controller.record_egress_fps(2_000, 8.0);
|
||||
controller.record_egress_fps(4_000, 7.0);
|
||||
controller.record_egress_fps(5_001, 7.0);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderEgressPressure");
|
||||
|
||||
controller.record_capture(10_002, 1, 1);
|
||||
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
assert_eq!(controller.tier_and_reason().0, "fps15");
|
||||
assert_eq!(controller.tier_and_reason().1, "encoderEgressPressure");
|
||||
|
||||
for index in 2..=12 {
|
||||
controller.record_capture(10_002 + index * 5_001, 1, 1);
|
||||
}
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(
|
||||
controller.tier_and_reason(),
|
||||
("full".to_string(), "stable".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_ignores_single_encoder_egress_sample() {
|
||||
let controller = AdaptiveVideoController::new(30.0, 15.0, true, 0);
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(1_000, 7.0);
|
||||
|
||||
controller.record_egress_fps(5_001, 7.0);
|
||||
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
assert_eq!(controller.tier_and_reason().1, "stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_keeps_requested_fps_when_adaptive_send_is_disabled() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, false, 0);
|
||||
for _ in 0..100 {
|
||||
controller.record_enqueue(1_000, false);
|
||||
}
|
||||
controller.record_capture(5_001, 200, 200);
|
||||
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
assert_eq!(
|
||||
controller.tier_and_reason(),
|
||||
("full".to_string(), "adaptiveDisabled".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_recovers_after_stable_windows() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
for index in 1..=12 {
|
||||
controller.record_capture(5_001 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_recovers_from_minimum_in_two_stable_steps() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
controller.record_capture(6_000, 70, 1);
|
||||
controller.record_capture(10_002, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 15.0);
|
||||
|
||||
for index in 1..=12 {
|
||||
controller.record_capture(10_002 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
for index in 13..=24 {
|
||||
controller.record_capture(10_002 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_requires_a_full_stable_recovery_window() {
|
||||
let controller = AdaptiveVideoController::new(60.0, 15.0, true, 0);
|
||||
controller.record_capture(1_000, 80, 1);
|
||||
controller.record_capture(5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
|
||||
for index in 1..12 {
|
||||
controller.record_capture(5_001 + index * 5_001, 1, 1);
|
||||
}
|
||||
assert_eq!(controller.current_fps(), 30.0);
|
||||
controller.record_capture(5_001 + 12 * 5_001, 1, 1);
|
||||
assert_eq!(controller.current_fps(), 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_stats_snapshot_counts_coalescing_drops_failures_and_effective_fps() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
let stats = AdaptiveVideoStats::new(60.0, 15.0, true, 0);
|
||||
|
||||
stats.record_enqueue(1_000, false);
|
||||
stats.record_enqueue(1_010, true);
|
||||
stats.record_drop(1_011);
|
||||
stats.record_capture(1_020, 20, 24);
|
||||
stats.record_enqueue(2_000, false);
|
||||
stats.record_capture(2_020, 20, 26);
|
||||
stats.record_capture_failure();
|
||||
|
||||
let snapshot = stats.snapshot(&audio, VideoTelemetryExtras::default());
|
||||
assert_eq!(snapshot.outgoing_video_frames_produced, 3);
|
||||
assert_eq!(snapshot.outgoing_video_frames_accepted, 3);
|
||||
assert_eq!(snapshot.outgoing_video_frames_dropped, 1);
|
||||
assert_eq!(snapshot.outgoing_video_frames_coalesced, 1);
|
||||
assert_eq!(snapshot.outgoing_video_frames_captured, 2);
|
||||
assert_eq!(snapshot.outgoing_video_capture_failures, 1);
|
||||
assert_eq!(snapshot.outgoing_video_effective_fps, 1.0);
|
||||
assert_eq!(snapshot.outgoing_video_max_queue_age_ms, 20);
|
||||
assert_eq!(snapshot.outgoing_video_max_push_latency_ms, 26);
|
||||
assert_eq!(snapshot.outgoing_video_queue_depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_stats_effective_fps_uses_capture_time_bounds_for_out_of_order_records() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
let stats = AdaptiveVideoStats::new(60.0, 15.0, true, 0);
|
||||
|
||||
stats.record_capture(2_000, 5, 6);
|
||||
stats.record_capture(1_000, 7, 8);
|
||||
stats.record_capture(3_000, 9, 10);
|
||||
stats.record_capture(2_500, 11, 12);
|
||||
|
||||
let snapshot = stats.snapshot(&audio, VideoTelemetryExtras::default());
|
||||
assert_eq!(snapshot.outgoing_video_frames_captured, 4);
|
||||
assert_eq!(snapshot.outgoing_video_effective_fps, 1.5);
|
||||
assert_eq!(snapshot.outgoing_video_max_queue_age_ms, 11);
|
||||
assert_eq!(snapshot.outgoing_video_max_push_latency_ms, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_stats_handles_concurrent_recording_without_lost_counts() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
let stats = Arc::new(AdaptiveVideoStats::new(60.0, 15.0, true, 0));
|
||||
let mut workers = Vec::new();
|
||||
for worker in 0..8 {
|
||||
let stats = stats.clone();
|
||||
workers.push(std::thread::spawn(move || {
|
||||
for index in 0..250 {
|
||||
let now_ms = 1_000 + worker * 1_000 + index;
|
||||
stats.record_enqueue(now_ms, index % 3 == 0);
|
||||
if index % 5 == 0 {
|
||||
stats.record_drop(now_ms);
|
||||
}
|
||||
if index % 7 == 0 {
|
||||
stats.record_capture(now_ms + 1, 1, 2);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("worker should not panic");
|
||||
}
|
||||
|
||||
let snapshot = stats.snapshot(&audio, VideoTelemetryExtras::default());
|
||||
assert_eq!(snapshot.outgoing_video_frames_produced, 2_000);
|
||||
assert_eq!(snapshot.outgoing_video_frames_accepted, 2_000);
|
||||
assert_eq!(snapshot.outgoing_video_frames_coalesced, 672);
|
||||
assert_eq!(snapshot.outgoing_video_frames_dropped, 400);
|
||||
assert_eq!(snapshot.outgoing_video_frames_captured, 288);
|
||||
assert_eq!(snapshot.outgoing_video_max_queue_age_ms, 1);
|
||||
assert_eq!(snapshot.outgoing_video_max_push_latency_ms, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_snapshot_reflects_audio_pressure_without_video_state() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
audio.record_push(1_000);
|
||||
audio.record_push(1_300);
|
||||
|
||||
let snapshot = SendHealthSnapshot::idle(&audio);
|
||||
assert_eq!(snapshot.outgoing_video_frames_produced, 0);
|
||||
assert_eq!(snapshot.outgoing_audio_buffer_target_ms, 300);
|
||||
assert_eq!(snapshot.outgoing_audio_buffer_max_ms, 750);
|
||||
assert_eq!(snapshot.outgoing_audio_rebuffers, 1);
|
||||
assert_eq!(snapshot.outgoing_audio_underruns, 1);
|
||||
assert_eq!(snapshot.outgoing_audio_max_frame_gap_ms, 300);
|
||||
assert_eq!(snapshot.adaptive_send_tier, "idle");
|
||||
assert_eq!(snapshot.adaptive_send_reason, "notPublishing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_controller_stress_keeps_target_inside_configured_bounds() {
|
||||
let controller = AdaptiveVideoController::new(144.0, 24.0, true, 0);
|
||||
for window in 0..240 {
|
||||
let base = window * 5_001;
|
||||
match window % 4 {
|
||||
0 => controller.record_capture(base + 1_000, 200, 1),
|
||||
1 => {
|
||||
for _ in 0..30 {
|
||||
controller.record_enqueue(base + 1_000, true);
|
||||
}
|
||||
controller.record_capture(base + 1_500, 1, 1);
|
||||
}
|
||||
2 => {
|
||||
for _ in 0..60 {
|
||||
controller.record_enqueue(base + 1_000, false);
|
||||
}
|
||||
controller.record_egress_fps(base + 1_500, 30.0);
|
||||
controller.record_egress_fps(base + 2_500, 30.0);
|
||||
}
|
||||
_ => controller.record_capture(base + 1_000, 1, 1),
|
||||
}
|
||||
controller.record_capture(base + 5_001, 1, 1);
|
||||
let fps = controller.current_fps();
|
||||
assert!(
|
||||
(24.0..=144.0).contains(&fps),
|
||||
"fps target escaped configured bounds: {fps}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_expands_on_gaps_and_shrinks_after_stability() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
audio.record_push(10);
|
||||
audio.record_push(200);
|
||||
assert_eq!(audio.target_buffer_ms(), 300);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 1);
|
||||
|
||||
for now_ms in (220..=30_240).step_by(20) {
|
||||
audio.record_push(now_ms);
|
||||
}
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_ignores_clock_regression_without_false_rebuffer() {
|
||||
let audio = AdaptiveAudioStats::new(750, 0);
|
||||
|
||||
audio.record_push(1_000);
|
||||
audio.record_push(1_020);
|
||||
audio.record_push(900);
|
||||
audio.record_push(1_040);
|
||||
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.max_frame_gap_ms.load(Ordering::Relaxed), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_ignores_concurrent_stale_pushes_without_moving_last_push_backwards() {
|
||||
let audio = Arc::new(AdaptiveAudioStats::new(750, 0));
|
||||
audio.record_push(10_000);
|
||||
|
||||
let mut workers = Vec::new();
|
||||
for worker in 0..8 {
|
||||
let audio = audio.clone();
|
||||
workers.push(std::thread::spawn(move || {
|
||||
for index in 0..100 {
|
||||
audio.record_push(1_000 + worker * 100 + index);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for worker in workers {
|
||||
worker.join().expect("worker should not panic");
|
||||
}
|
||||
|
||||
audio.record_push(10_020);
|
||||
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.max_frame_gap_ms.load(Ordering::Relaxed), 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_growth_caps_at_configured_max_and_reset_clears_pressure() {
|
||||
let audio = AdaptiveAudioStats::new(350, 0);
|
||||
audio.record_push(10);
|
||||
for index in 1..=10 {
|
||||
audio.record_push(10 + index * 500);
|
||||
}
|
||||
assert_eq!(audio.target_buffer_ms(), 350);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 10);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 10);
|
||||
|
||||
audio.reset(250, 10_000);
|
||||
assert_eq!(audio.target_buffer_ms(), 200);
|
||||
assert_eq!(audio.max_buffer_ms(), 250);
|
||||
assert_eq!(audio.rebuffers.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.underruns.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(audio.max_frame_gap_ms.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_stress_stays_within_realtime_bounds_under_jitter() {
|
||||
let audio = AdaptiveAudioStats::new(620, 0);
|
||||
let mut now_ms = 10;
|
||||
audio.record_push(now_ms);
|
||||
|
||||
for index in 0..5_000 {
|
||||
now_ms += match index % 11 {
|
||||
0 => 180,
|
||||
1 | 2 => 80,
|
||||
_ => 20,
|
||||
};
|
||||
audio.record_push(now_ms);
|
||||
assert!(
|
||||
(DEFAULT_AUDIO_BUFFER_TARGET_MS..=620).contains(&audio.target_buffer_ms()),
|
||||
"audio target escaped configured bounds"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(audio.max_buffer_ms(), 620);
|
||||
assert!(audio.rebuffers.load(Ordering::Relaxed) > 0);
|
||||
assert!(audio.max_frame_gap_ms.load(Ordering::Relaxed) >= 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_max_is_clamped_to_real_time_bounds() {
|
||||
assert_eq!(clamp_audio_buffer_ms(50), 200);
|
||||
assert_eq!(clamp_audio_buffer_ms(777), 750);
|
||||
assert_eq!(clamp_audio_buffer_ms(333), 330);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub const SPEAKING_ATTACK_MS: u64 = 30;
|
||||
pub const SPEAKING_RELEASE_MS_LOCAL: u64 = 180;
|
||||
pub const SPEAKING_RELEASE_MS_REMOTE: u64 = 220;
|
||||
pub const SPEAKING_HEARTBEAT_INTERVAL_MS: u64 = 1_000;
|
||||
pub const SPEAKING_FRAME_TIMEOUT_MS: u64 = 250;
|
||||
pub const SPEAKING_THRESHOLD_RMS_LOCAL_DEFAULT: f64 = 0.008;
|
||||
pub const SPEAKING_THRESHOLD_RMS_REMOTE_DEFAULT: f64 = 0.006;
|
||||
pub const SPEAKING_THRESHOLD_RMS_MIN: f64 = 0.000_1;
|
||||
pub const SPEAKING_THRESHOLD_RMS_MAX: f64 = 0.5;
|
||||
pub const SPEAKING_FRAME_SAMPLES_MAX: usize = 1 << 20;
|
||||
|
||||
const _: () = assert!(SPEAKING_ATTACK_MS < SPEAKING_RELEASE_MS_LOCAL);
|
||||
const _: () = assert!(SPEAKING_ATTACK_MS < SPEAKING_RELEASE_MS_REMOTE);
|
||||
const _: () = assert!(SPEAKING_RELEASE_MS_REMOTE < SPEAKING_HEARTBEAT_INTERVAL_MS);
|
||||
const _: () = assert!(SPEAKING_FRAME_TIMEOUT_MS < SPEAKING_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
pub fn clamp_speaking_threshold_rms(threshold_rms: f64) -> f64 {
|
||||
if !threshold_rms.is_finite() {
|
||||
return SPEAKING_THRESHOLD_RMS_MIN;
|
||||
}
|
||||
threshold_rms.clamp(SPEAKING_THRESHOLD_RMS_MIN, SPEAKING_THRESHOLD_RMS_MAX)
|
||||
}
|
||||
|
||||
pub struct SpeakingThresholds {
|
||||
local_rms_bits: AtomicU64,
|
||||
remote_rms_bits: AtomicU64,
|
||||
}
|
||||
|
||||
impl SpeakingThresholds {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
local_rms_bits: AtomicU64::new(SPEAKING_THRESHOLD_RMS_LOCAL_DEFAULT.to_bits()),
|
||||
remote_rms_bits: AtomicU64::new(SPEAKING_THRESHOLD_RMS_REMOTE_DEFAULT.to_bits()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&self, local_rms: f64, remote_rms: f64) {
|
||||
let local = clamp_speaking_threshold_rms(local_rms);
|
||||
let remote = clamp_speaking_threshold_rms(remote_rms);
|
||||
assert!(local >= SPEAKING_THRESHOLD_RMS_MIN);
|
||||
assert!(remote >= SPEAKING_THRESHOLD_RMS_MIN);
|
||||
self.local_rms_bits
|
||||
.store(local.to_bits(), Ordering::Release);
|
||||
self.remote_rms_bits
|
||||
.store(remote.to_bits(), Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn local_rms(&self) -> f64 {
|
||||
let value = f64::from_bits(self.local_rms_bits.load(Ordering::Acquire));
|
||||
assert!(value.is_finite());
|
||||
value
|
||||
}
|
||||
|
||||
pub fn remote_rms(&self) -> f64 {
|
||||
let value = f64::from_bits(self.remote_rms_bits.load(Ordering::Acquire));
|
||||
assert!(value.is_finite());
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SpeakingThresholds {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame_rms_i16(samples: &[i16]) -> f64 {
|
||||
assert!(!samples.is_empty());
|
||||
assert!(samples.len() <= SPEAKING_FRAME_SAMPLES_MAX);
|
||||
let mut sum_squares: f64 = 0.0;
|
||||
for sample in samples {
|
||||
let normalized = f64::from(*sample) / 32_768.0;
|
||||
sum_squares += normalized * normalized;
|
||||
}
|
||||
let rms = (sum_squares / samples.len() as f64).sqrt();
|
||||
assert!(rms.is_finite());
|
||||
assert!(rms >= 0.0);
|
||||
rms.min(1.0)
|
||||
}
|
||||
|
||||
pub struct SpeakingGate {
|
||||
attack_ms: u64,
|
||||
release_ms: u64,
|
||||
speaking: bool,
|
||||
above_since_ms: Option<u64>,
|
||||
below_since_ms: Option<u64>,
|
||||
last_now_ms: u64,
|
||||
}
|
||||
|
||||
impl SpeakingGate {
|
||||
pub fn new(attack_ms: u64, release_ms: u64) -> Self {
|
||||
assert!(attack_ms < release_ms);
|
||||
assert!(release_ms <= SPEAKING_HEARTBEAT_INTERVAL_MS);
|
||||
Self {
|
||||
attack_ms,
|
||||
release_ms,
|
||||
speaking: false,
|
||||
above_since_ms: None,
|
||||
below_since_ms: None,
|
||||
last_now_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn speaking(&self) -> bool {
|
||||
self.speaking
|
||||
}
|
||||
|
||||
pub fn update(&mut self, rms: f64, threshold_rms: f64, now_ms: u64) -> Option<bool> {
|
||||
assert!(rms.is_finite());
|
||||
assert!(rms >= 0.0);
|
||||
assert!(threshold_rms >= SPEAKING_THRESHOLD_RMS_MIN);
|
||||
assert!(threshold_rms <= SPEAKING_THRESHOLD_RMS_MAX);
|
||||
assert!(now_ms >= self.last_now_ms);
|
||||
self.last_now_ms = now_ms;
|
||||
if rms >= threshold_rms {
|
||||
self.below_since_ms = None;
|
||||
let above_since_ms = *self.above_since_ms.get_or_insert(now_ms);
|
||||
if self.speaking {
|
||||
return None;
|
||||
}
|
||||
if now_ms - above_since_ms < self.attack_ms {
|
||||
return None;
|
||||
}
|
||||
self.speaking = true;
|
||||
return Some(true);
|
||||
}
|
||||
self.above_since_ms = None;
|
||||
let below_since_ms = *self.below_since_ms.get_or_insert(now_ms);
|
||||
if !self.speaking {
|
||||
return None;
|
||||
}
|
||||
if now_ms - below_since_ms < self.release_ms {
|
||||
return None;
|
||||
}
|
||||
self.speaking = false;
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const THRESHOLD: f64 = 0.01;
|
||||
|
||||
fn gate() -> SpeakingGate {
|
||||
SpeakingGate::new(SPEAKING_ATTACK_MS, SPEAKING_RELEASE_MS_LOCAL)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_quiet_below_threshold() {
|
||||
let mut gate = gate();
|
||||
for tick in 0..100u64 {
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, tick * 10), None);
|
||||
}
|
||||
assert!(!gate.speaking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_requires_sustained_signal() {
|
||||
let mut gate = gate();
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 0), None);
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 10), None);
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 20), None);
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 30), Some(true));
|
||||
assert!(gate.speaking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_frame_blip_does_not_trigger() {
|
||||
let mut gate = gate();
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 0), None);
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, 10), None);
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 20), None);
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, 30), None);
|
||||
assert!(!gate.speaking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_bridges_inter_word_gaps() {
|
||||
let mut gate = gate();
|
||||
for tick in 0..=3u64 {
|
||||
gate.update(0.5, THRESHOLD, tick * 10);
|
||||
}
|
||||
assert!(gate.speaking());
|
||||
for tick in 4..=20u64 {
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, tick * 10), None);
|
||||
}
|
||||
assert!(gate.speaking());
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 210), None);
|
||||
assert!(gate.speaking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_fires_after_sustained_silence() {
|
||||
let mut gate = gate();
|
||||
for tick in 0..=3u64 {
|
||||
gate.update(0.5, THRESHOLD, tick * 10);
|
||||
}
|
||||
assert!(gate.speaking());
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, 40), None);
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, 219), None);
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, 220), Some(false));
|
||||
assert!(!gate.speaking());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retrigger_after_release_needs_full_attack() {
|
||||
let mut gate = gate();
|
||||
for tick in 0..=3u64 {
|
||||
gate.update(0.5, THRESHOLD, tick * 10);
|
||||
}
|
||||
gate.update(0.001, THRESHOLD, 40);
|
||||
assert_eq!(gate.update(0.001, THRESHOLD, 220), Some(false));
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 230), None);
|
||||
assert_eq!(gate.update(0.5, THRESHOLD, 260), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_rms_of_silence_is_zero() {
|
||||
let samples = [0i16; 480];
|
||||
assert_eq!(frame_rms_i16(&samples), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_rms_of_full_scale_square_wave_is_one() {
|
||||
let mut samples = [i16::MIN; 480];
|
||||
for (index, sample) in samples.iter_mut().enumerate() {
|
||||
if index % 2 == 0 {
|
||||
*sample = i16::MAX;
|
||||
}
|
||||
}
|
||||
let rms = frame_rms_i16(&samples);
|
||||
assert!(rms > 0.999);
|
||||
assert!(rms <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_rms_scales_with_amplitude() {
|
||||
let loud = [8_192i16; 480];
|
||||
let quiet = [1_024i16; 480];
|
||||
assert!(frame_rms_i16(&loud) > frame_rms_i16(&quiet));
|
||||
assert!((frame_rms_i16(&loud) - 0.25).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thresholds_default_and_clamp() {
|
||||
let thresholds = SpeakingThresholds::new();
|
||||
assert_eq!(thresholds.local_rms(), SPEAKING_THRESHOLD_RMS_LOCAL_DEFAULT);
|
||||
assert_eq!(
|
||||
thresholds.remote_rms(),
|
||||
SPEAKING_THRESHOLD_RMS_REMOTE_DEFAULT
|
||||
);
|
||||
thresholds.set(-1.0, f64::NAN);
|
||||
assert_eq!(thresholds.local_rms(), SPEAKING_THRESHOLD_RMS_MIN);
|
||||
assert_eq!(thresholds.remote_rms(), SPEAKING_THRESHOLD_RMS_MIN);
|
||||
thresholds.set(9.0, 0.02);
|
||||
assert_eq!(thresholds.local_rms(), SPEAKING_THRESHOLD_RMS_MAX);
|
||||
assert_eq!(thresholds.remote_rms(), 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_update_is_monotonic_in_time() {
|
||||
let mut gate = gate();
|
||||
gate.update(0.5, THRESHOLD, 100);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
gate.update(0.5, THRESHOLD, 50);
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::send_control::SendHealthSnapshot;
|
||||
use fluxer_desktop_native::voice::stats as core_stats;
|
||||
|
||||
pub type ByteRateSample = core_stats::ByteRateSample;
|
||||
pub type OutboundEntry = core_stats::OutboundStatsEntry;
|
||||
pub type InboundEntry = core_stats::InboundStatsEntry;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct ConnectionStats {
|
||||
pub rtt_ms: Option<f64>,
|
||||
pub outbound: Vec<OutboundEntry>,
|
||||
pub inbound: Vec<InboundEntry>,
|
||||
pub send: Option<SendHealthSnapshot>,
|
||||
}
|
||||
|
||||
pub fn bitrate_kbps(prev: Option<ByteRateSample>, cur: ByteRateSample) -> f64 {
|
||||
core_stats::bitrate_kbps(prev, cur)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn sanitize_kbps(kbps: f64) -> f64 {
|
||||
core_stats::sanitize_kbps(kbps)
|
||||
}
|
||||
|
||||
pub fn jitter_seconds_to_ms(jitter_s: f64) -> Option<f64> {
|
||||
core_stats::jitter_seconds_to_ms(jitter_s)
|
||||
}
|
||||
|
||||
pub fn rtt_seconds_to_ms(rtt_s: f64) -> Option<f64> {
|
||||
core_stats::rtt_seconds_to_ms(rtt_s)
|
||||
}
|
||||
|
||||
pub fn sanitize_audio_level(level: f64) -> Option<f64> {
|
||||
core_stats::sanitize_audio_level(level)
|
||||
}
|
||||
|
||||
pub fn stats_to_json(stats: &ConnectionStats) -> String {
|
||||
core_stats::stats_to_json(&core_stats::ConnectionStats {
|
||||
rtt_ms: stats.rtt_ms,
|
||||
outbound: stats.outbound.clone(),
|
||||
inbound: stats.inbound.clone(),
|
||||
send: stats.send.as_ref().map(send_health_to_core),
|
||||
})
|
||||
}
|
||||
|
||||
fn send_health_to_core(send: &SendHealthSnapshot) -> core_stats::SendHealthStats {
|
||||
core_stats::SendHealthStats {
|
||||
outgoing_video_queue_depth: send.outgoing_video_queue_depth,
|
||||
outgoing_video_queue_capacity: send.outgoing_video_queue_capacity,
|
||||
outgoing_video_max_queue_depth: send.outgoing_video_max_queue_depth,
|
||||
outgoing_video_frames_produced: send.outgoing_video_frames_produced,
|
||||
outgoing_video_frames_accepted: send.outgoing_video_frames_accepted,
|
||||
outgoing_video_frames_dropped: send.outgoing_video_frames_dropped,
|
||||
outgoing_video_frames_coalesced: send.outgoing_video_frames_coalesced,
|
||||
outgoing_video_frames_captured: send.outgoing_video_frames_captured,
|
||||
outgoing_video_capture_failures: send.outgoing_video_capture_failures,
|
||||
outgoing_video_effective_fps: send.outgoing_video_effective_fps,
|
||||
outgoing_video_target_fps: send.outgoing_video_target_fps,
|
||||
outgoing_video_pacing_target_fps: send.outgoing_video_pacing_target_fps,
|
||||
outgoing_video_max_queue_age_ms: send.outgoing_video_max_queue_age_ms,
|
||||
outgoing_video_max_push_latency_ms: send.outgoing_video_max_push_latency_ms,
|
||||
outgoing_video_pacing_mode: send.outgoing_video_pacing_mode.clone(),
|
||||
outgoing_video_bus_active: send.outgoing_video_bus_active,
|
||||
outgoing_audio_buffer_target_ms: send.outgoing_audio_buffer_target_ms,
|
||||
outgoing_audio_buffer_max_ms: send.outgoing_audio_buffer_max_ms,
|
||||
outgoing_audio_underruns: send.outgoing_audio_underruns,
|
||||
outgoing_audio_rebuffers: send.outgoing_audio_rebuffers,
|
||||
outgoing_audio_max_frame_gap_ms: send.outgoing_audio_max_frame_gap_ms,
|
||||
adaptive_send_tier: send.adaptive_send_tier.clone(),
|
||||
adaptive_send_reason: send.adaptive_send_reason.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bitrate_first_sample_is_zero() {
|
||||
let cur = ByteRateSample {
|
||||
bytes: 1000,
|
||||
timestamp_us: 1_000_000,
|
||||
};
|
||||
assert_eq!(bitrate_kbps(None, cur), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_computes_kbps_from_byte_delta() {
|
||||
let prev = ByteRateSample {
|
||||
bytes: 0,
|
||||
timestamp_us: 0,
|
||||
};
|
||||
let cur = ByteRateSample {
|
||||
bytes: 12_500,
|
||||
timestamp_us: 1_000_000,
|
||||
};
|
||||
assert_eq!(bitrate_kbps(Some(prev), cur), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_half_second_doubles_rate() {
|
||||
let prev = ByteRateSample {
|
||||
bytes: 1000,
|
||||
timestamp_us: 1_000_000,
|
||||
};
|
||||
let cur = ByteRateSample {
|
||||
bytes: 13_500,
|
||||
timestamp_us: 1_500_000,
|
||||
};
|
||||
assert_eq!(bitrate_kbps(Some(prev), cur), 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitrate_rejects_backwards_time_and_bytes() {
|
||||
let prev = ByteRateSample {
|
||||
bytes: 5000,
|
||||
timestamp_us: 2_000_000,
|
||||
};
|
||||
assert_eq!(
|
||||
bitrate_kbps(
|
||||
Some(prev),
|
||||
ByteRateSample {
|
||||
bytes: 6000,
|
||||
timestamp_us: 1_000_000
|
||||
}
|
||||
),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
bitrate_kbps(
|
||||
Some(prev),
|
||||
ByteRateSample {
|
||||
bytes: 6000,
|
||||
timestamp_us: 2_000_000
|
||||
}
|
||||
),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
bitrate_kbps(
|
||||
Some(prev),
|
||||
ByteRateSample {
|
||||
bytes: 100,
|
||||
timestamp_us: 3_000_000
|
||||
}
|
||||
),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_kbps_drops_nan_inf_negative_and_rounds() {
|
||||
assert_eq!(sanitize_kbps(f64::NAN), 0.0);
|
||||
assert_eq!(sanitize_kbps(f64::INFINITY), 0.0);
|
||||
assert_eq!(sanitize_kbps(-5.0), 0.0);
|
||||
assert_eq!(sanitize_kbps(123.456), 123.5);
|
||||
assert_eq!(sanitize_kbps(100.0), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_conversions_seconds_to_ms() {
|
||||
assert_eq!(jitter_seconds_to_ms(0.012), Some(12.0));
|
||||
assert_eq!(jitter_seconds_to_ms(-1.0), None);
|
||||
assert_eq!(jitter_seconds_to_ms(f64::NAN), None);
|
||||
assert_eq!(rtt_seconds_to_ms(0.045), Some(45.0));
|
||||
assert_eq!(rtt_seconds_to_ms(0.0), None);
|
||||
assert_eq!(rtt_seconds_to_ms(f64::INFINITY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_audio_level_clamps() {
|
||||
assert_eq!(sanitize_audio_level(0.5), Some(0.5));
|
||||
assert_eq!(sanitize_audio_level(2.0), Some(1.0));
|
||||
assert_eq!(sanitize_audio_level(-0.1), Some(0.0));
|
||||
assert_eq!(sanitize_audio_level(f64::NAN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_stats_serialise_to_null_rtt_and_empty_arrays() {
|
||||
let stats = ConnectionStats::default();
|
||||
assert_eq!(
|
||||
stats_to_json(&stats),
|
||||
"{\"rttMs\":null,\"outbound\":[],\"inbound\":[],\"send\":null}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_stats_serialise_to_exact_contract_shape() {
|
||||
let stats = ConnectionStats {
|
||||
rtt_ms: Some(42.0),
|
||||
outbound: vec![
|
||||
OutboundEntry {
|
||||
track_sid: "TR_mic1".into(),
|
||||
source: "microphone".into(),
|
||||
kind: "audio".into(),
|
||||
codec: Some("audio/opus".into()),
|
||||
bitrate_kbps: 32.0,
|
||||
packets_lost: 0,
|
||||
fps: None,
|
||||
audio_level: Some(0.62),
|
||||
..Default::default()
|
||||
},
|
||||
OutboundEntry {
|
||||
track_sid: "TR_screen1".into(),
|
||||
source: "screen_share".into(),
|
||||
kind: "video".into(),
|
||||
codec: Some("video/H265".into()),
|
||||
bitrate_kbps: 2500.5,
|
||||
packets_lost: 3,
|
||||
packets_sent: 9000,
|
||||
fps: Some(30.0),
|
||||
audio_level: None,
|
||||
width: Some(2176),
|
||||
height: Some(1200),
|
||||
source_width: Some(2176),
|
||||
source_height: Some(1200),
|
||||
target_bitrate_kbps: Some(50_000.0),
|
||||
configured_fps: Some(60.0),
|
||||
target_fps: Some(30.0),
|
||||
effective_fps: Some(29.8),
|
||||
frames_produced: Some(120),
|
||||
frames_accepted: Some(118),
|
||||
frames_dropped: Some(1),
|
||||
frames_coalesced: Some(2),
|
||||
frames_captured: Some(117),
|
||||
capture_failures: Some(0),
|
||||
max_queue_age_ms: Some(18),
|
||||
max_push_latency_ms: Some(12),
|
||||
adaptive_send_tier: Some("fps30".into()),
|
||||
adaptive_send_reason: Some("encoderEgressPressure".into()),
|
||||
},
|
||||
],
|
||||
inbound: vec![InboundEntry {
|
||||
participant_sid: "PA_remote1".into(),
|
||||
participant_identity: Some("user_2_connection_2".into()),
|
||||
track_sid: "TR_remoteAudio".into(),
|
||||
source: Some("microphone".into()),
|
||||
kind: "audio".into(),
|
||||
codec: Some("audio/opus".into()),
|
||||
bitrate_kbps: 28.0,
|
||||
packets_lost: 1,
|
||||
packets_received: 990,
|
||||
jitter_ms: Some(5.0),
|
||||
audio_level: Some(0.75),
|
||||
fps: None,
|
||||
width: None,
|
||||
height: None,
|
||||
source_width: None,
|
||||
source_height: None,
|
||||
}],
|
||||
send: None,
|
||||
};
|
||||
let json = stats_to_json(&stats);
|
||||
assert_eq!(
|
||||
json,
|
||||
"{\"rttMs\":42,\"outbound\":[\
|
||||
{\"trackSid\":\"TR_mic1\",\"source\":\"microphone\",\"kind\":\"audio\",\"bitrateKbps\":32,\"packetsLost\":0,\"packetsSent\":0,\"audioLevel\":0.62,\"codec\":\"audio/opus\"},\
|
||||
{\"trackSid\":\"TR_screen1\",\"source\":\"screen_share\",\"kind\":\"video\",\"bitrateKbps\":2500.5,\"packetsLost\":3,\"packetsSent\":9000,\"fps\":30,\"width\":2176,\"height\":1200,\"sourceWidth\":2176,\"sourceHeight\":1200,\"targetBitrateKbps\":50000,\"configuredFps\":60,\"targetFps\":30,\"effectiveFps\":29.8,\"framesProduced\":120,\"framesAccepted\":118,\"framesDropped\":1,\"framesCoalesced\":2,\"framesCaptured\":117,\"captureFailures\":0,\"maxQueueAgeMs\":18,\"maxPushLatencyMs\":12,\"adaptiveSendTier\":\"fps30\",\"adaptiveSendReason\":\"encoderEgressPressure\",\"codec\":\"video/H265\"}\
|
||||
],\"inbound\":[\
|
||||
{\"participantSid\":\"PA_remote1\",\"trackSid\":\"TR_remoteAudio\",\"kind\":\"audio\",\"bitrateKbps\":28,\"packetsLost\":1,\"packetsReceived\":990,\"participantIdentity\":\"user_2_connection_2\",\"source\":\"microphone\",\"jitterMs\":5,\"audioLevel\":0.75,\"codec\":\"audio/opus\"}\
|
||||
],\"send\":null}"
|
||||
);
|
||||
let _ = json;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_health_serialises_to_exact_contract_shape() {
|
||||
let stats = ConnectionStats {
|
||||
rtt_ms: None,
|
||||
outbound: vec![],
|
||||
inbound: vec![],
|
||||
send: Some(SendHealthSnapshot {
|
||||
outgoing_video_queue_depth: 1,
|
||||
outgoing_video_queue_capacity: 8,
|
||||
outgoing_video_max_queue_depth: 4,
|
||||
outgoing_video_frames_produced: 2,
|
||||
outgoing_video_frames_accepted: 3,
|
||||
outgoing_video_frames_dropped: 4,
|
||||
outgoing_video_frames_coalesced: 5,
|
||||
outgoing_video_frames_captured: 6,
|
||||
outgoing_video_capture_failures: 7,
|
||||
outgoing_video_effective_fps: 59.94,
|
||||
outgoing_video_target_fps: 30.0,
|
||||
outgoing_video_pacing_target_fps: 60.0,
|
||||
outgoing_video_max_queue_age_ms: 8,
|
||||
outgoing_video_max_push_latency_ms: 9,
|
||||
outgoing_video_pacing_mode: "source".to_string(),
|
||||
outgoing_video_bus_active: true,
|
||||
outgoing_audio_buffer_target_ms: 300,
|
||||
outgoing_audio_buffer_max_ms: 750,
|
||||
outgoing_audio_underruns: 10,
|
||||
outgoing_audio_rebuffers: 11,
|
||||
outgoing_audio_max_frame_gap_ms: 120,
|
||||
adaptive_send_tier: "fps30".to_string(),
|
||||
adaptive_send_reason: "sendLatencyPressure".to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
stats_to_json(&stats),
|
||||
"{\"rttMs\":null,\"outbound\":[],\"inbound\":[],\"send\":{\
|
||||
\"outgoingVideoQueueDepth\":1,\
|
||||
\"outgoingVideoQueueCapacity\":8,\
|
||||
\"outgoingVideoMaxQueueDepth\":4,\
|
||||
\"outgoingVideoFramesProduced\":2,\
|
||||
\"outgoingVideoFramesAccepted\":3,\
|
||||
\"outgoingVideoFramesDropped\":4,\
|
||||
\"outgoingVideoFramesCoalesced\":5,\
|
||||
\"outgoingVideoFramesCaptured\":6,\
|
||||
\"outgoingVideoCaptureFailures\":7,\
|
||||
\"outgoingVideoEffectiveFps\":59.94,\
|
||||
\"outgoingVideoTargetFps\":30,\
|
||||
\"outgoingVideoPacingTargetFps\":60,\
|
||||
\"outgoingVideoMaxQueueAgeMs\":8,\
|
||||
\"outgoingVideoMaxPushLatencyMs\":9,\
|
||||
\"outgoingVideoPacingMode\":\"source\",\
|
||||
\"outgoingVideoBusActive\":true,\
|
||||
\"outgoingAudioBufferTargetMs\":300,\
|
||||
\"outgoingAudioBufferMaxMs\":750,\
|
||||
\"outgoingAudioUnderruns\":10,\
|
||||
\"outgoingAudioRebuffers\":11,\
|
||||
\"outgoingAudioMaxFrameGapMs\":120,\
|
||||
\"adaptiveSendTier\":\"fps30\",\
|
||||
\"adaptiveSendReason\":\"sendLatencyPressure\"\
|
||||
}}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_inbound_omits_audio_level_audio_omits_fps() {
|
||||
let stats = ConnectionStats {
|
||||
rtt_ms: None,
|
||||
outbound: vec![],
|
||||
inbound: vec![InboundEntry {
|
||||
participant_sid: "PA_x".into(),
|
||||
participant_identity: None,
|
||||
track_sid: "TR_v".into(),
|
||||
source: Some("screen_share".into()),
|
||||
kind: "video".into(),
|
||||
codec: None,
|
||||
bitrate_kbps: 1000.0,
|
||||
packets_lost: 0,
|
||||
packets_received: 5000,
|
||||
jitter_ms: None,
|
||||
audio_level: None,
|
||||
fps: Some(29.94),
|
||||
width: Some(3840),
|
||||
height: Some(2160),
|
||||
source_width: Some(3840),
|
||||
source_height: Some(2160),
|
||||
}],
|
||||
send: None,
|
||||
};
|
||||
let json = stats_to_json(&stats);
|
||||
assert!(!json.contains("audioLevel"));
|
||||
assert!(!json.contains("jitterMs"));
|
||||
assert!(json.contains("\"source\":\"screen_share\""));
|
||||
assert!(json.contains("\"fps\":29.9"));
|
||||
assert!(json.contains("\"width\":3840"));
|
||||
assert!(json.contains("\"height\":2160"));
|
||||
assert!(json.contains("\"rttMs\":null"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TextureFrameDesc {
|
||||
pub handle: u64,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub dxgi_format: u32,
|
||||
pub timestamp_us: i64,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DmabufFrameDesc {
|
||||
pub plane_count: u8,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub drm_format: u32,
|
||||
pub modifier: u64,
|
||||
pub strides: [u32; 4],
|
||||
pub offsets: [u32; 4],
|
||||
pub device_uuid: [u8; 16],
|
||||
pub timestamp_us: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TextureEncodeError {
|
||||
NoTexture,
|
||||
InvalidDimensions,
|
||||
UnsupportedFormat,
|
||||
InvalidPlanes,
|
||||
UnsupportedCodec,
|
||||
NoHardwareEncoder,
|
||||
SdkNativeTextureUnsupported,
|
||||
}
|
||||
|
||||
impl TextureEncodeError {
|
||||
#[allow(dead_code)]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TextureEncodeError::NoTexture => "noTexture",
|
||||
TextureEncodeError::InvalidDimensions => "invalidDimensions",
|
||||
TextureEncodeError::UnsupportedFormat => "unsupportedFormat",
|
||||
TextureEncodeError::InvalidPlanes => "invalidPlanes",
|
||||
TextureEncodeError::UnsupportedCodec => "unsupportedCodec",
|
||||
TextureEncodeError::NoHardwareEncoder => "noHardwareEncoder",
|
||||
TextureEncodeError::SdkNativeTextureUnsupported => "sdkNativeTextureUnsupported",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const DXGI_FORMAT_R8G8B8A8_UNORM: u32 = 28;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: u32 = 29;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const DXGI_FORMAT_B8G8R8A8_UNORM: u32 = 87;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: u32 = 91;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const DXGI_FORMAT_NV12: u32 = 103;
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub fn dxgi_format_supported(dxgi_format: u32) -> bool {
|
||||
matches!(
|
||||
dxgi_format,
|
||||
DXGI_FORMAT_R8G8B8A8_UNORM
|
||||
| DXGI_FORMAT_R8G8B8A8_UNORM_SRGB
|
||||
| DXGI_FORMAT_B8G8R8A8_UNORM
|
||||
| DXGI_FORMAT_B8G8R8A8_UNORM_SRGB
|
||||
| DXGI_FORMAT_NV12
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const fn fourcc(bytes: [u8; 4]) -> u32 {
|
||||
u32::from_le_bytes(bytes)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_XRGB8888: u32 = fourcc(*b"XR24");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_ARGB8888: u32 = fourcc(*b"AR24");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_XBGR8888: u32 = fourcc(*b"XB24");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_ABGR8888: u32 = fourcc(*b"AB24");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_XRGB2101010: u32 = fourcc(*b"XR30");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_ARGB2101010: u32 = fourcc(*b"AR30");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_XBGR2101010: u32 = fourcc(*b"XB30");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_ABGR2101010: u32 = fourcc(*b"AB30");
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const DRM_FORMAT_NV12: u32 = fourcc(*b"NV12");
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn drm_format_supported(drm_format: u32) -> bool {
|
||||
matches!(
|
||||
drm_format,
|
||||
DRM_FORMAT_XRGB8888
|
||||
| DRM_FORMAT_ARGB8888
|
||||
| DRM_FORMAT_XBGR8888
|
||||
| DRM_FORMAT_ABGR8888
|
||||
| DRM_FORMAT_XRGB2101010
|
||||
| DRM_FORMAT_ARGB2101010
|
||||
| DRM_FORMAT_XBGR2101010
|
||||
| DRM_FORMAT_ABGR2101010
|
||||
| DRM_FORMAT_NV12
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows", test))]
|
||||
const MAX_TEXTURE_EDGE: u32 = 8192;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "windows", test))]
|
||||
fn validate_dimensions(width: u32, height: u32) -> Result<(), TextureEncodeError> {
|
||||
if width < 2
|
||||
|| height < 2
|
||||
|| !width.is_multiple_of(2)
|
||||
|| !height.is_multiple_of(2)
|
||||
|| width > MAX_TEXTURE_EDGE
|
||||
|| height > MAX_TEXTURE_EDGE
|
||||
{
|
||||
return Err(TextureEncodeError::InvalidDimensions);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub fn validate_texture_desc(desc: &TextureFrameDesc) -> Result<(), TextureEncodeError> {
|
||||
if desc.handle == 0 {
|
||||
return Err(TextureEncodeError::NoTexture);
|
||||
}
|
||||
validate_dimensions(desc.width, desc.height)?;
|
||||
if !dxgi_format_supported(desc.dxgi_format) {
|
||||
return Err(TextureEncodeError::UnsupportedFormat);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub fn validate_dmabuf_desc(desc: &DmabufFrameDesc) -> Result<(), TextureEncodeError> {
|
||||
validate_dimensions(desc.width, desc.height)?;
|
||||
if !drm_format_supported(desc.drm_format) {
|
||||
return Err(TextureEncodeError::UnsupportedFormat);
|
||||
}
|
||||
let plane_count = desc.plane_count as usize;
|
||||
if !(1..=4).contains(&plane_count) {
|
||||
return Err(TextureEncodeError::InvalidPlanes);
|
||||
}
|
||||
for plane in 0..plane_count {
|
||||
if desc.strides[plane] == 0 {
|
||||
return Err(TextureEncodeError::InvalidPlanes);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg(test)]
|
||||
pub fn dmabuf_desc_from_parts(
|
||||
fds: &[i32],
|
||||
plane_count: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
drm_format: u32,
|
||||
modifier: u64,
|
||||
strides: &[u32],
|
||||
offsets: &[u32],
|
||||
device_uuid: &[u8],
|
||||
timestamp_us: f64,
|
||||
) -> Option<(DmabufFrameDesc, [i32; 4])> {
|
||||
let plane_count_u8 = u8::try_from(plane_count).ok()?;
|
||||
let planes = plane_count as usize;
|
||||
if !(1..=4).contains(&planes)
|
||||
|| fds.len() < planes
|
||||
|| strides.len() < planes
|
||||
|| offsets.len() < planes
|
||||
|| device_uuid.len() != 16
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if fds.iter().take(planes).any(|fd| *fd < 0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut fd_array = [-1; 4];
|
||||
let mut stride_array = [0; 4];
|
||||
let mut offset_array = [0; 4];
|
||||
fd_array[..planes].copy_from_slice(&fds[..planes]);
|
||||
stride_array[..planes].copy_from_slice(&strides[..planes]);
|
||||
offset_array[..planes].copy_from_slice(&offsets[..planes]);
|
||||
let mut uuid = [0u8; 16];
|
||||
uuid.copy_from_slice(device_uuid);
|
||||
|
||||
Some((
|
||||
DmabufFrameDesc {
|
||||
plane_count: plane_count_u8,
|
||||
width,
|
||||
height,
|
||||
drm_format,
|
||||
modifier,
|
||||
strides: stride_array,
|
||||
offsets: offset_array,
|
||||
device_uuid: uuid,
|
||||
timestamp_us: timestamp_us as i64,
|
||||
},
|
||||
fd_array,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TextureCapability {
|
||||
pub available: bool,
|
||||
pub reason: TextureEncodeError,
|
||||
}
|
||||
|
||||
impl TextureCapability {
|
||||
pub fn unavailable(reason: TextureEncodeError) -> Self {
|
||||
Self {
|
||||
available: false,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_screen_codec(codec: &str, has_hardware_encoder: bool) -> Self {
|
||||
if !codec_allows_native_gpu(codec) {
|
||||
return Self::unavailable(TextureEncodeError::UnsupportedCodec);
|
||||
}
|
||||
if !has_hardware_encoder {
|
||||
return Self {
|
||||
available: false,
|
||||
reason: TextureEncodeError::NoHardwareEncoder,
|
||||
};
|
||||
}
|
||||
Self {
|
||||
available: true,
|
||||
reason: TextureEncodeError::NoTexture,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn codec_allows_native_gpu(codec: &str) -> bool {
|
||||
matches!(
|
||||
codec.trim().to_ascii_lowercase().as_str(),
|
||||
"h264" | "h265" | "hevc"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn sdk_accepts_d3d11_texture_buffers() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn sdk_accepts_dmabuf_texture_buffers() -> bool {
|
||||
cfg!(target_os = "linux")
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub fn should_attempt_texture_encode(
|
||||
capability: &TextureCapability,
|
||||
desc: &TextureFrameDesc,
|
||||
) -> Result<(), TextureEncodeError> {
|
||||
if !capability.available {
|
||||
return Err(capability.reason);
|
||||
}
|
||||
if !sdk_accepts_d3d11_texture_buffers() {
|
||||
return Err(TextureEncodeError::SdkNativeTextureUnsupported);
|
||||
}
|
||||
validate_texture_desc(desc)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn should_attempt_dmabuf_encode(
|
||||
capability: &TextureCapability,
|
||||
desc: &DmabufFrameDesc,
|
||||
) -> Result<(), TextureEncodeError> {
|
||||
if !capability.available {
|
||||
return Err(capability.reason);
|
||||
}
|
||||
if !sdk_accepts_dmabuf_texture_buffers() {
|
||||
return Err(TextureEncodeError::SdkNativeTextureUnsupported);
|
||||
}
|
||||
validate_dmabuf_desc(desc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn should_attempt_texture_encode_for_tests(
|
||||
capability: &TextureCapability,
|
||||
desc: &TextureFrameDesc,
|
||||
sdk_accepts_d3d11: bool,
|
||||
) -> Result<(), TextureEncodeError> {
|
||||
if !capability.available {
|
||||
return Err(capability.reason);
|
||||
}
|
||||
if !sdk_accepts_d3d11 {
|
||||
return Err(TextureEncodeError::SdkNativeTextureUnsupported);
|
||||
}
|
||||
validate_texture_desc(desc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn should_attempt_dmabuf_encode_for_tests(
|
||||
capability: &TextureCapability,
|
||||
desc: &DmabufFrameDesc,
|
||||
sdk_accepts_dmabuf: bool,
|
||||
) -> Result<(), TextureEncodeError> {
|
||||
if !capability.available {
|
||||
return Err(capability.reason);
|
||||
}
|
||||
if !sdk_accepts_dmabuf {
|
||||
return Err(TextureEncodeError::SdkNativeTextureUnsupported);
|
||||
}
|
||||
validate_dmabuf_desc(desc)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "publisher", any(target_os = "linux", target_os = "windows")))]
|
||||
pub mod bridge {
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::{DmabufFrameDesc, should_attempt_dmabuf_encode};
|
||||
use super::{TextureCapability, TextureEncodeError};
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::{TextureFrameDesc, should_attempt_texture_encode};
|
||||
use livekit::webrtc::video_frame::{VideoFrame, VideoRotation, native::NativeBuffer};
|
||||
use livekit::webrtc::video_source::native::NativeVideoSource;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn try_publish_texture(
|
||||
source: &NativeVideoSource,
|
||||
capability: &TextureCapability,
|
||||
desc: &TextureFrameDesc,
|
||||
) -> Result<(), TextureEncodeError> {
|
||||
should_attempt_texture_encode(capability, desc)?;
|
||||
|
||||
let Some(buffer) = NativeBuffer::from_fluxer_d3d11_texture(
|
||||
desc.handle,
|
||||
desc.width,
|
||||
desc.height,
|
||||
desc.dxgi_format,
|
||||
) else {
|
||||
return Err(TextureEncodeError::SdkNativeTextureUnsupported);
|
||||
};
|
||||
publish_native_buffer(source, buffer, desc.timestamp_us);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn try_publish_dmabuf(
|
||||
source: &NativeVideoSource,
|
||||
capability: &TextureCapability,
|
||||
desc: &DmabufFrameDesc,
|
||||
fds: [i32; 4],
|
||||
) -> Result<(), TextureEncodeError> {
|
||||
should_attempt_dmabuf_encode(capability, desc)?;
|
||||
let uuid_hi = u64::from_be_bytes(desc.device_uuid[0..8].try_into().unwrap_or([0; 8]));
|
||||
let uuid_lo = u64::from_be_bytes(desc.device_uuid[8..16].try_into().unwrap_or([0; 8]));
|
||||
let Some(buffer) = NativeBuffer::from_fluxer_dmabuf_texture(
|
||||
fds,
|
||||
desc.plane_count as u32,
|
||||
desc.width,
|
||||
desc.height,
|
||||
desc.drm_format,
|
||||
desc.modifier,
|
||||
desc.strides,
|
||||
desc.offsets,
|
||||
uuid_hi,
|
||||
uuid_lo,
|
||||
) else {
|
||||
return Err(TextureEncodeError::SdkNativeTextureUnsupported);
|
||||
};
|
||||
publish_native_buffer(source, buffer, desc.timestamp_us);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_native_buffer(source: &NativeVideoSource, buffer: NativeBuffer, timestamp_us: i64) {
|
||||
source.capture_frame(&VideoFrame {
|
||||
rotation: VideoRotation::VideoRotation0,
|
||||
timestamp_us,
|
||||
frame_metadata: None,
|
||||
buffer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn good_desc() -> TextureFrameDesc {
|
||||
TextureFrameDesc {
|
||||
handle: 0xDEAD_BEEF,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
dxgi_format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||
timestamp_us: 123_456,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxgi_format_gate_accepts_8bit_rgba_bgra_only() {
|
||||
assert!(dxgi_format_supported(DXGI_FORMAT_B8G8R8A8_UNORM));
|
||||
assert!(dxgi_format_supported(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB));
|
||||
assert!(dxgi_format_supported(DXGI_FORMAT_R8G8B8A8_UNORM));
|
||||
assert!(dxgi_format_supported(DXGI_FORMAT_R8G8B8A8_UNORM_SRGB));
|
||||
assert!(dxgi_format_supported(DXGI_FORMAT_NV12));
|
||||
assert!(!dxgi_format_supported(24));
|
||||
assert!(!dxgi_format_supported(10));
|
||||
assert!(!dxgi_format_supported(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drm_format_gate_accepts_obs_vkcapture_texture_formats() {
|
||||
assert!(drm_format_supported(DRM_FORMAT_ARGB8888));
|
||||
assert!(drm_format_supported(DRM_FORMAT_ABGR8888));
|
||||
assert!(drm_format_supported(DRM_FORMAT_ARGB2101010));
|
||||
assert!(drm_format_supported(DRM_FORMAT_ABGR2101010));
|
||||
assert!(drm_format_supported(DRM_FORMAT_NV12));
|
||||
assert!(
|
||||
!drm_format_supported(fourcc(*b"AB4H")),
|
||||
"16-bit float DMA-BUF import is not encodable by the native NVENC bridge yet"
|
||||
);
|
||||
assert!(!drm_format_supported(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_zero_handle_as_no_texture() {
|
||||
let mut d = good_desc();
|
||||
d.handle = 0;
|
||||
assert_eq!(
|
||||
validate_texture_desc(&d),
|
||||
Err(TextureEncodeError::NoTexture)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_odd_zero_and_oversized_dims() {
|
||||
for (w, h) in [
|
||||
(1921, 1080),
|
||||
(1920, 1081),
|
||||
(0, 1080),
|
||||
(1920, 0),
|
||||
(8194, 1080),
|
||||
(1920, 8194),
|
||||
] {
|
||||
let mut d = good_desc();
|
||||
d.width = w;
|
||||
d.height = h;
|
||||
assert_eq!(
|
||||
validate_texture_desc(&d),
|
||||
Err(TextureEncodeError::InvalidDimensions),
|
||||
"dims {w}x{h} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_unsupported_format() {
|
||||
let mut d = good_desc();
|
||||
d.dxgi_format = 24;
|
||||
assert_eq!(
|
||||
validate_texture_desc(&d),
|
||||
Err(TextureEncodeError::UnsupportedFormat)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_a_clean_bgra_texture() {
|
||||
assert_eq!(validate_texture_desc(&good_desc()), Ok(()));
|
||||
}
|
||||
|
||||
fn good_dmabuf_desc() -> DmabufFrameDesc {
|
||||
DmabufFrameDesc {
|
||||
plane_count: 1,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
drm_format: DRM_FORMAT_ARGB8888,
|
||||
modifier: 0,
|
||||
strides: [1920 * 4, 0, 0, 0],
|
||||
offsets: [0, 0, 0, 0],
|
||||
device_uuid: [1; 16],
|
||||
timestamp_us: 123_456,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dmabuf_accepts_supported_formats_with_optional_uuid() {
|
||||
assert_eq!(validate_dmabuf_desc(&good_dmabuf_desc()), Ok(()));
|
||||
let mut desc = good_dmabuf_desc();
|
||||
desc.drm_format = DRM_FORMAT_NV12;
|
||||
desc.strides[0] = 1920;
|
||||
assert_eq!(validate_dmabuf_desc(&desc), Ok(()));
|
||||
desc = good_dmabuf_desc();
|
||||
desc.device_uuid = [0; 16];
|
||||
assert_eq!(validate_dmabuf_desc(&desc), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dmabuf_rejects_invalid_planes() {
|
||||
let mut desc = good_dmabuf_desc();
|
||||
desc.plane_count = 0;
|
||||
assert_eq!(
|
||||
validate_dmabuf_desc(&desc),
|
||||
Err(TextureEncodeError::InvalidPlanes)
|
||||
);
|
||||
desc = good_dmabuf_desc();
|
||||
desc.strides[0] = 0;
|
||||
assert_eq!(
|
||||
validate_dmabuf_desc(&desc),
|
||||
Err(TextureEncodeError::InvalidPlanes)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmabuf_desc_from_parts_rejects_negative_fds() {
|
||||
assert!(
|
||||
dmabuf_desc_from_parts(
|
||||
&[-1],
|
||||
1,
|
||||
1920,
|
||||
1080,
|
||||
DRM_FORMAT_ARGB8888,
|
||||
0,
|
||||
&[1920 * 4],
|
||||
&[0],
|
||||
&[1; 16],
|
||||
123.0,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmabuf_desc_from_parts_populates_all_plane_arrays() {
|
||||
let uuid = [7u8; 16];
|
||||
let (desc, fds) = dmabuf_desc_from_parts(
|
||||
&[10, 11, 12, 99],
|
||||
3,
|
||||
1920,
|
||||
1080,
|
||||
DRM_FORMAT_NV12,
|
||||
0xABCD,
|
||||
&[1920, 960, 960, 777],
|
||||
&[0, 2_073_600, 3_110_400, 999],
|
||||
&uuid,
|
||||
123_456.75,
|
||||
)
|
||||
.expect("valid multi-plane descriptor");
|
||||
|
||||
assert_eq!(desc.plane_count, 3);
|
||||
assert_eq!(desc.width, 1920);
|
||||
assert_eq!(desc.height, 1080);
|
||||
assert_eq!(desc.drm_format, DRM_FORMAT_NV12);
|
||||
assert_eq!(desc.modifier, 0xABCD);
|
||||
assert_eq!(desc.strides, [1920, 960, 960, 0]);
|
||||
assert_eq!(desc.offsets, [0, 2_073_600, 3_110_400, 0]);
|
||||
assert_eq!(desc.device_uuid, uuid);
|
||||
assert_eq!(desc.timestamp_us, 123_456);
|
||||
assert_eq!(fds, [10, 11, 12, -1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmabuf_desc_from_parts_rejects_incomplete_native_inputs() {
|
||||
let uuid = [1u8; 16];
|
||||
for (fds, strides, offsets, uuid_bytes, label) in [
|
||||
(&[4][..], &[128][..], &[0][..], &uuid[..], "too few fds"),
|
||||
(
|
||||
&[4, 5][..],
|
||||
&[128][..],
|
||||
&[0, 64][..],
|
||||
&uuid[..],
|
||||
"too few strides",
|
||||
),
|
||||
(
|
||||
&[4, 5][..],
|
||||
&[128, 128][..],
|
||||
&[0][..],
|
||||
&uuid[..],
|
||||
"too few offsets",
|
||||
),
|
||||
(
|
||||
&[4, 5][..],
|
||||
&[128, 128][..],
|
||||
&[0, 64][..],
|
||||
&[1u8; 15][..],
|
||||
"bad uuid",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
dmabuf_desc_from_parts(
|
||||
fds,
|
||||
2,
|
||||
128,
|
||||
128,
|
||||
DRM_FORMAT_ARGB8888,
|
||||
0,
|
||||
strides,
|
||||
offsets,
|
||||
uuid_bytes,
|
||||
0.0,
|
||||
)
|
||||
.is_none(),
|
||||
"{label} should be rejected"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
dmabuf_desc_from_parts(
|
||||
&[4, 5, 6, 7, 8],
|
||||
5,
|
||||
128,
|
||||
128,
|
||||
DRM_FORMAT_ARGB8888,
|
||||
0,
|
||||
&[128; 5],
|
||||
&[0; 5],
|
||||
&uuid,
|
||||
0.0,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_precedence_handle_before_dims_before_format() {
|
||||
let d = TextureFrameDesc {
|
||||
handle: 0,
|
||||
width: 1921,
|
||||
height: 0,
|
||||
dxgi_format: 999,
|
||||
timestamp_us: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
validate_texture_desc(&d),
|
||||
Err(TextureEncodeError::NoTexture)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_is_available_only_for_explicit_hardware_codecs() {
|
||||
for codec in ["h264", "H264", "h265", "hevc", "HEVC"] {
|
||||
let cap = TextureCapability::for_screen_codec(codec, true);
|
||||
assert!(cap.available, "{codec} should allow native GPU buffers");
|
||||
assert_eq!(cap.reason, TextureEncodeError::NoTexture);
|
||||
}
|
||||
for codec in ["", "vp8", "vp9", "av1", "rubbish"] {
|
||||
let cap = TextureCapability::for_screen_codec(codec, true);
|
||||
assert!(
|
||||
!cap.available,
|
||||
"{codec} should not allow native GPU buffers"
|
||||
);
|
||||
assert_eq!(cap.reason, TextureEncodeError::UnsupportedCodec);
|
||||
}
|
||||
let cap = TextureCapability::for_screen_codec("h264", false);
|
||||
assert!(!cap.available);
|
||||
assert_eq!(cap.reason, TextureEncodeError::NoHardwareEncoder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_attempt_falls_back_when_capability_unavailable() {
|
||||
let cap = TextureCapability {
|
||||
available: false,
|
||||
reason: TextureEncodeError::SdkNativeTextureUnsupported,
|
||||
};
|
||||
assert_eq!(
|
||||
should_attempt_texture_encode(&cap, &good_desc()),
|
||||
Err(TextureEncodeError::SdkNativeTextureUnsupported)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_attempt_validates_frame_when_capability_available() {
|
||||
let open = TextureCapability {
|
||||
available: true,
|
||||
reason: TextureEncodeError::NoTexture,
|
||||
};
|
||||
assert_eq!(
|
||||
should_attempt_texture_encode_for_tests(&open, &good_desc(), true),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(should_attempt_texture_encode(&open, &good_desc()), Ok(()));
|
||||
let mut bad = good_desc();
|
||||
bad.handle = 0;
|
||||
assert_eq!(
|
||||
should_attempt_texture_encode_for_tests(&open, &bad, true),
|
||||
Err(TextureEncodeError::NoTexture)
|
||||
);
|
||||
bad = good_desc();
|
||||
bad.dxgi_format = 24;
|
||||
assert_eq!(
|
||||
should_attempt_texture_encode_for_tests(&open, &bad, true),
|
||||
Err(TextureEncodeError::UnsupportedFormat)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_attempt_dmabuf_is_sdk_gated_after_validation_capability() {
|
||||
let open = TextureCapability {
|
||||
available: true,
|
||||
reason: TextureEncodeError::NoTexture,
|
||||
};
|
||||
assert_eq!(
|
||||
should_attempt_dmabuf_encode_for_tests(&open, &good_dmabuf_desc(), true),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
should_attempt_dmabuf_encode_for_tests(&open, &good_dmabuf_desc(), false),
|
||||
Err(TextureEncodeError::SdkNativeTextureUnsupported)
|
||||
);
|
||||
let mut invalid = good_dmabuf_desc();
|
||||
invalid.plane_count = 5;
|
||||
assert_eq!(
|
||||
should_attempt_dmabuf_encode_for_tests(&open, &invalid, true),
|
||||
Err(TextureEncodeError::InvalidPlanes)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_strings_are_stable() {
|
||||
assert_eq!(TextureEncodeError::NoTexture.as_str(), "noTexture");
|
||||
assert_eq!(
|
||||
TextureEncodeError::InvalidDimensions.as_str(),
|
||||
"invalidDimensions"
|
||||
);
|
||||
assert_eq!(
|
||||
TextureEncodeError::UnsupportedFormat.as_str(),
|
||||
"unsupportedFormat"
|
||||
);
|
||||
assert_eq!(TextureEncodeError::InvalidPlanes.as_str(), "invalidPlanes");
|
||||
assert_eq!(
|
||||
TextureEncodeError::UnsupportedCodec.as_str(),
|
||||
"unsupportedCodec"
|
||||
);
|
||||
assert_eq!(
|
||||
TextureEncodeError::NoHardwareEncoder.as_str(),
|
||||
"noHardwareEncoder"
|
||||
);
|
||||
assert_eq!(
|
||||
TextureEncodeError::SdkNativeTextureUnsupported.as_str(),
|
||||
"sdkNativeTextureUnsupported"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct I420 {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub y: Vec<u8>,
|
||||
pub u: Vec<u8>,
|
||||
pub v: Vec<u8>,
|
||||
}
|
||||
|
||||
impl I420 {
|
||||
pub fn new(width: u32, height: u32) -> Option<Self> {
|
||||
if !dims_ok(width, height) {
|
||||
return None;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
Some(Self {
|
||||
width,
|
||||
height,
|
||||
y: vec![0u8; w * h],
|
||||
u: vec![0u8; (w / 2) * (h / 2)],
|
||||
v: vec![0u8; (w / 2) * (h / 2)],
|
||||
})
|
||||
}
|
||||
|
||||
fn has_layout(&self, width: u32, height: u32) -> bool {
|
||||
if self.width != width || self.height != height {
|
||||
return false;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
self.y.len() == w * h && self.u.len() == (w / 2) * (h / 2) && self.v.len() == self.u.len()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tight_i420_byte_len(width: u32, height: u32) -> Option<usize> {
|
||||
if !dims_ok(width, height) {
|
||||
return None;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let y_len = w.checked_mul(h)?;
|
||||
let chroma_len = (w / 2).checked_mul(h / 2)?;
|
||||
y_len.checked_add(chroma_len.checked_mul(2)?)
|
||||
}
|
||||
|
||||
pub fn copy_tight_i420_into(src: &[u8], width: u32, height: u32, dst: &mut I420) -> bool {
|
||||
if !dst.has_layout(width, height) {
|
||||
return false;
|
||||
}
|
||||
let Some(total_len) = tight_i420_byte_len(width, height) else {
|
||||
return false;
|
||||
};
|
||||
if src.len() != total_len {
|
||||
return false;
|
||||
}
|
||||
let y_len = (width as usize) * (height as usize);
|
||||
let chroma_len = y_len / 4;
|
||||
dst.y.copy_from_slice(&src[..y_len]);
|
||||
dst.u.copy_from_slice(&src[y_len..y_len + chroma_len]);
|
||||
dst.v.copy_from_slice(&src[y_len + chroma_len..]);
|
||||
true
|
||||
}
|
||||
|
||||
fn dims_ok(width: u32, height: u32) -> bool {
|
||||
width >= 2 && height >= 2 && width.is_multiple_of(2) && height.is_multiple_of(2)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn nv12_to_i420(
|
||||
src: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride_y: u32,
|
||||
stride_uv: u32,
|
||||
) -> Option<I420> {
|
||||
let mut dst = I420::new(width, height)?;
|
||||
if !nv12_to_i420_into(src, width, height, stride_y, stride_uv, &mut dst) {
|
||||
return None;
|
||||
}
|
||||
Some(dst)
|
||||
}
|
||||
|
||||
pub fn nv12_to_i420_into(
|
||||
src: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride_y: u32,
|
||||
stride_uv: u32,
|
||||
dst: &mut I420,
|
||||
) -> bool {
|
||||
if !dims_ok(width, height) {
|
||||
return false;
|
||||
}
|
||||
if !dst.has_layout(width, height) {
|
||||
return false;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let cw = w / 2;
|
||||
let ch = h / 2;
|
||||
let sy = stride_y.max(width) as usize;
|
||||
let suv = stride_uv.max(width) as usize;
|
||||
|
||||
let Some(uv_offset) = sy.checked_mul(h) else {
|
||||
return false;
|
||||
};
|
||||
let Some(uv_len) = suv.checked_mul(ch) else {
|
||||
return false;
|
||||
};
|
||||
let Some(needed) = uv_offset.checked_add(uv_len) else {
|
||||
return false;
|
||||
};
|
||||
if src.len() < needed {
|
||||
return false;
|
||||
}
|
||||
|
||||
for row in 0..h {
|
||||
let s = row * sy;
|
||||
dst.y[row * w..row * w + w].copy_from_slice(&src[s..s + w]);
|
||||
}
|
||||
|
||||
for row in 0..ch {
|
||||
let base = uv_offset + row * suv;
|
||||
for x in 0..cw {
|
||||
dst.u[row * cw + x] = src[base + 2 * x];
|
||||
dst.v[row * cw + x] = src[base + 2 * x + 1];
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn copy_nv12_planes(
|
||||
src: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride_y: u32,
|
||||
stride_uv: u32,
|
||||
dst_y: &mut [u8],
|
||||
dst_uv: &mut [u8],
|
||||
dst_stride_y: u32,
|
||||
dst_stride_uv: u32,
|
||||
) -> bool {
|
||||
if !dims_ok(width, height) {
|
||||
return false;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let ch = h / 2;
|
||||
let sy = stride_y.max(width) as usize;
|
||||
let suv = stride_uv.max(width) as usize;
|
||||
let dsy = dst_stride_y as usize;
|
||||
let dsuv = dst_stride_uv as usize;
|
||||
if dsy < w || dsuv < w {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(uv_offset) = sy.checked_mul(h) else {
|
||||
return false;
|
||||
};
|
||||
let Some(uv_len) = suv.checked_mul(ch) else {
|
||||
return false;
|
||||
};
|
||||
let Some(needed) = uv_offset.checked_add(uv_len) else {
|
||||
return false;
|
||||
};
|
||||
if src.len() < needed || dst_y.len() < dsy * h || dst_uv.len() < dsuv * ch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for row in 0..h {
|
||||
let s = row * sy;
|
||||
let d = row * dsy;
|
||||
dst_y[d..d + w].copy_from_slice(&src[s..s + w]);
|
||||
}
|
||||
for row in 0..ch {
|
||||
let s = uv_offset + row * suv;
|
||||
let d = row * dsuv;
|
||||
dst_uv[d..d + w].copy_from_slice(&src[s..s + w]);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn yuyv_to_i420(src: &[u8], width: u32, height: u32, stride: u32) -> Option<I420> {
|
||||
let mut dst = I420::new(width, height)?;
|
||||
if !yuyv_to_i420_into(src, width, height, stride, &mut dst) {
|
||||
return None;
|
||||
}
|
||||
Some(dst)
|
||||
}
|
||||
|
||||
pub fn yuyv_to_i420_into(src: &[u8], width: u32, height: u32, stride: u32, dst: &mut I420) -> bool {
|
||||
if !dims_ok(width, height) {
|
||||
return false;
|
||||
}
|
||||
if !dst.has_layout(width, height) {
|
||||
return false;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let cw = w / 2;
|
||||
let ch = h / 2;
|
||||
let stride = stride.max(width * 2) as usize;
|
||||
if src.len() < stride * h {
|
||||
return false;
|
||||
}
|
||||
|
||||
for row in 0..h {
|
||||
let row_base = row * stride;
|
||||
for pair in 0..cw {
|
||||
let src_offset = row_base + pair * 4;
|
||||
let dst_offset = row * w + pair * 2;
|
||||
dst.y[dst_offset] = src[src_offset];
|
||||
dst.y[dst_offset + 1] = src[src_offset + 2];
|
||||
}
|
||||
}
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let top = (cy * 2) * stride + cx * 4;
|
||||
let bottom = (cy * 2 + 1) * stride + cx * 4;
|
||||
dst.u[cy * cw + cx] =
|
||||
((u16::from(src[top + 1]) + u16::from(src[bottom + 1])) / 2) as u8;
|
||||
dst.v[cy * cw + cx] =
|
||||
((u16::from(src[top + 3]) + u16::from(src[bottom + 3])) / 2) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn clamp_u8(value: i32) -> u8 {
|
||||
value.clamp(0, 255) as u8
|
||||
}
|
||||
|
||||
fn rgb_to_y(r: i32, g: i32, b: i32) -> u8 {
|
||||
clamp_u8(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16)
|
||||
}
|
||||
fn rgb_to_u(r: i32, g: i32, b: i32) -> u8 {
|
||||
clamp_u8(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128)
|
||||
}
|
||||
fn rgb_to_v(r: i32, g: i32, b: i32) -> u8 {
|
||||
clamp_u8(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn bgra_to_i420(src: &[u8], width: u32, height: u32, stride: u32) -> Option<I420> {
|
||||
let mut dst = I420::new(width, height)?;
|
||||
if !bgra_to_i420_planes(
|
||||
src,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
&mut dst.y,
|
||||
&mut dst.u,
|
||||
&mut dst.v,
|
||||
width,
|
||||
width / 2,
|
||||
width / 2,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(dst)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn bgra_to_i420_planes(
|
||||
src: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: u32,
|
||||
dst_y: &mut [u8],
|
||||
dst_u: &mut [u8],
|
||||
dst_v: &mut [u8],
|
||||
dst_stride_y: u32,
|
||||
dst_stride_u: u32,
|
||||
dst_stride_v: u32,
|
||||
) -> bool {
|
||||
if !dims_ok(width, height) {
|
||||
return false;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let cw = w / 2;
|
||||
let ch = h / 2;
|
||||
let stride = stride.max(width * 4) as usize;
|
||||
if src.len() < stride * h {
|
||||
return false;
|
||||
}
|
||||
let dsy = dst_stride_y as usize;
|
||||
let dsu = dst_stride_u as usize;
|
||||
let dsv = dst_stride_v as usize;
|
||||
if dsy < w || dsu < cw || dsv < cw {
|
||||
return false;
|
||||
}
|
||||
if dst_y.len() < dsy * h || dst_u.len() < dsu * ch || dst_v.len() < dsv * ch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let px = |row: usize, col: usize| -> (i32, i32, i32) {
|
||||
let o = row * stride + col * 4;
|
||||
let b = src[o] as i32;
|
||||
let g = src[o + 1] as i32;
|
||||
let r = src[o + 2] as i32;
|
||||
(r, g, b)
|
||||
};
|
||||
|
||||
for row in 0..h {
|
||||
for col in 0..w {
|
||||
let (r, g, b) = px(row, col);
|
||||
dst_y[row * dsy + col] = rgb_to_y(r, g, b);
|
||||
}
|
||||
}
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let mut rs = 0;
|
||||
let mut gs = 0;
|
||||
let mut bs = 0;
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let (r, g, b) = px(cy * 2 + dy, cx * 2 + dx);
|
||||
rs += r;
|
||||
gs += g;
|
||||
bs += b;
|
||||
}
|
||||
}
|
||||
let (r, g, b) = (rs / 4, gs / 4, bs / 4);
|
||||
dst_u[cy * dsu + cx] = rgb_to_u(r, g, b);
|
||||
dst_v[cy * dsv + cx] = rgb_to_v(r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "camera-native"), allow(dead_code))]
|
||||
pub fn rgb_to_i420(src: &[u8], width: u32, height: u32) -> Option<I420> {
|
||||
let mut dst = I420::new(width, height)?;
|
||||
if !rgb_to_i420_into(src, width, height, &mut dst) {
|
||||
return None;
|
||||
}
|
||||
Some(dst)
|
||||
}
|
||||
|
||||
pub fn rgb_to_i420_into(src: &[u8], width: u32, height: u32, dst: &mut I420) -> bool {
|
||||
if !dims_ok(width, height) {
|
||||
return false;
|
||||
}
|
||||
if !dst.has_layout(width, height) {
|
||||
return false;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let cw = w / 2;
|
||||
let ch = h / 2;
|
||||
let stride = w * 3;
|
||||
if src.len() < stride * h {
|
||||
return false;
|
||||
}
|
||||
|
||||
let px = |row: usize, col: usize| -> (i32, i32, i32) {
|
||||
let o = row * stride + col * 3;
|
||||
let r = src[o] as i32;
|
||||
let g = src[o + 1] as i32;
|
||||
let b = src[o + 2] as i32;
|
||||
(r, g, b)
|
||||
};
|
||||
|
||||
for row in 0..h {
|
||||
for col in 0..w {
|
||||
let (r, g, b) = px(row, col);
|
||||
dst.y[row * w + col] = rgb_to_y(r, g, b);
|
||||
}
|
||||
}
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let mut rs = 0;
|
||||
let mut gs = 0;
|
||||
let mut bs = 0;
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let (r, g, b) = px(cy * 2 + dy, cx * 2 + dx);
|
||||
rs += r;
|
||||
gs += g;
|
||||
bs += b;
|
||||
}
|
||||
}
|
||||
let (r, g, b) = (rs / 4, gs / 4, bs / 4);
|
||||
dst.u[cy * cw + cx] = rgb_to_u(r, g, b);
|
||||
dst.v[cy * cw + cx] = rgb_to_v(r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "camera-native"), allow(dead_code))]
|
||||
pub fn bgr_to_i420_into(src: &[u8], width: u32, height: u32, dst: &mut I420) -> bool {
|
||||
if !dims_ok(width, height) {
|
||||
return false;
|
||||
}
|
||||
if !dst.has_layout(width, height) {
|
||||
return false;
|
||||
}
|
||||
let w = width as usize;
|
||||
let h = height as usize;
|
||||
let cw = w / 2;
|
||||
let ch = h / 2;
|
||||
let stride = w * 3;
|
||||
if src.len() < stride * h {
|
||||
return false;
|
||||
}
|
||||
|
||||
let px = |row: usize, col: usize| -> (i32, i32, i32) {
|
||||
let o = row * stride + col * 3;
|
||||
let b = src[o] as i32;
|
||||
let g = src[o + 1] as i32;
|
||||
let r = src[o + 2] as i32;
|
||||
(r, g, b)
|
||||
};
|
||||
|
||||
for row in 0..h {
|
||||
for col in 0..w {
|
||||
let (r, g, b) = px(row, col);
|
||||
dst.y[row * w + col] = rgb_to_y(r, g, b);
|
||||
}
|
||||
}
|
||||
for cy in 0..ch {
|
||||
for cx in 0..cw {
|
||||
let mut rs = 0;
|
||||
let mut gs = 0;
|
||||
let mut bs = 0;
|
||||
for dy in 0..2 {
|
||||
for dx in 0..2 {
|
||||
let (r, g, b) = px(cy * 2 + dy, cx * 2 + dx);
|
||||
rs += r;
|
||||
gs += g;
|
||||
bs += b;
|
||||
}
|
||||
}
|
||||
let (r, g, b) = (rs / 4, gs / 4, bs / 4);
|
||||
dst.u[cy * cw + cx] = rgb_to_u(r, g, b);
|
||||
dst.v[cy * cw + cx] = rgb_to_v(r, g, b);
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_odd_or_tiny_dimensions() {
|
||||
assert!(nv12_to_i420(&[0u8; 64], 3, 2, 3, 3).is_none());
|
||||
assert!(nv12_to_i420(&[0u8; 64], 2, 1, 2, 2).is_none());
|
||||
assert!(bgra_to_i420(&[0u8; 256], 2, 3, 8).is_none());
|
||||
assert!(bgra_to_i420(&[0u8; 256], 0, 2, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nv12_short_buffer_is_rejected() {
|
||||
assert!(nv12_to_i420(&[0u8; 5], 2, 2, 2, 2).is_none());
|
||||
assert!(nv12_to_i420(&[0u8; 6], 2, 2, 2, 2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nv12_packed_2x2_deinterleaves() {
|
||||
let src = [1u8, 2, 3, 4, 10, 20];
|
||||
let out = nv12_to_i420(&src, 2, 2, 2, 2).unwrap();
|
||||
assert_eq!(out.y, vec![1, 2, 3, 4]);
|
||||
assert_eq!(out.u, vec![10]);
|
||||
assert_eq!(out.v, vec![20]);
|
||||
assert_eq!((out.width / 2, out.height / 2), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuyv_2x2_deinterleaves_and_vertically_averages_chroma() {
|
||||
let src = [1u8, 10, 2, 20, 3, 30, 4, 40];
|
||||
let out = yuyv_to_i420(&src, 2, 2, 4).unwrap();
|
||||
|
||||
assert_eq!(out.y, vec![1, 2, 3, 4]);
|
||||
assert_eq!(out.u, vec![20]);
|
||||
assert_eq!(out.v, vec![30]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yuyv_respects_row_padding() {
|
||||
let src = [1u8, 10, 2, 20, 99, 99, 3, 30, 4, 40, 88, 88];
|
||||
let out = yuyv_to_i420(&src, 2, 2, 6).unwrap();
|
||||
|
||||
assert_eq!(out.y, vec![1, 2, 3, 4]);
|
||||
assert_eq!(out.u, vec![20]);
|
||||
assert_eq!(out.v, vec![30]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nv12_4x4_deinterleaves_two_chroma_columns() {
|
||||
let mut src = Vec::new();
|
||||
src.extend(0u8..16);
|
||||
src.extend([100, 101, 102, 103, 104, 105, 106, 107]);
|
||||
let out = nv12_to_i420(&src, 4, 4, 4, 4).unwrap();
|
||||
assert_eq!(out.y, (0u8..16).collect::<Vec<_>>());
|
||||
assert_eq!(out.u, vec![100, 102, 104, 106]);
|
||||
assert_eq!(out.v, vec![101, 103, 105, 107]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nv12_respects_row_padding() {
|
||||
let src = [1u8, 2, 0xFF, 0xFF, 3, 4, 0xFF, 0xFF, 10, 20, 0xFF, 0xFF];
|
||||
let out = nv12_to_i420(&src, 2, 2, 4, 4).unwrap();
|
||||
assert_eq!(out.y, vec![1, 2, 3, 4]);
|
||||
assert_eq!(out.u, vec![10]);
|
||||
assert_eq!(out.v, vec![20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_nv12_planes_preserves_nv12_layout() {
|
||||
let src = [1u8, 2, 3, 4, 10, 20];
|
||||
let mut y = [0u8; 4];
|
||||
let mut uv = [0u8; 2];
|
||||
assert!(copy_nv12_planes(&src, 2, 2, 2, 2, &mut y, &mut uv, 2, 2));
|
||||
assert_eq!(y, [1, 2, 3, 4]);
|
||||
assert_eq!(uv, [10, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_nv12_planes_respects_destination_stride() {
|
||||
let src = [1u8, 2, 0xFF, 0xFF, 3, 4, 0xFF, 0xFF, 10, 20, 0xFF, 0xFF];
|
||||
let mut y = [0u8; 8];
|
||||
let mut uv = [0u8; 4];
|
||||
assert!(copy_nv12_planes(&src, 2, 2, 4, 4, &mut y, &mut uv, 4, 4));
|
||||
assert_eq!(y, [1, 2, 0, 0, 3, 4, 0, 0]);
|
||||
assert_eq!(uv, [10, 20, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_nv12_planes_rejects_short_buffers() {
|
||||
let src = [0u8; 6];
|
||||
let mut y = [0u8; 3];
|
||||
let mut uv = [0u8; 2];
|
||||
assert!(!copy_nv12_planes(&src, 2, 2, 2, 2, &mut y, &mut uv, 2, 2));
|
||||
let mut y = [0u8; 4];
|
||||
assert!(!copy_nv12_planes(
|
||||
&src[..5],
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
&mut y,
|
||||
&mut uv,
|
||||
2,
|
||||
2
|
||||
));
|
||||
}
|
||||
|
||||
fn solid_bgra(width: u32, height: u32, b: u8, g: u8, r: u8) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity((width * height * 4) as usize);
|
||||
for _ in 0..(width * height) {
|
||||
v.extend([b, g, r, 255]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn near(a: u8, b: u8, tol: i32) -> bool {
|
||||
(a as i32 - b as i32).abs() <= tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra_black_white_grey_levels() {
|
||||
let black = bgra_to_i420(&solid_bgra(2, 2, 0, 0, 0), 2, 2, 8).unwrap();
|
||||
assert!(near(black.y[0], 16, 1), "black Y={}", black.y[0]);
|
||||
assert!(near(black.u[0], 128, 1) && near(black.v[0], 128, 1));
|
||||
|
||||
let white = bgra_to_i420(&solid_bgra(2, 2, 255, 255, 255), 2, 2, 8).unwrap();
|
||||
assert!(near(white.y[0], 235, 2), "white Y={}", white.y[0]);
|
||||
assert!(near(white.u[0], 128, 2) && near(white.v[0], 128, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra_primaries_have_expected_chroma_signs() {
|
||||
let red = bgra_to_i420(&solid_bgra(2, 2, 0, 0, 255), 2, 2, 8).unwrap();
|
||||
assert!(red.v[0] > 200, "red V={}", red.v[0]);
|
||||
let blue = bgra_to_i420(&solid_bgra(2, 2, 255, 0, 0), 2, 2, 8).unwrap();
|
||||
assert!(blue.u[0] > 200, "blue U={}", blue.u[0]);
|
||||
let green = bgra_to_i420(&solid_bgra(2, 2, 0, 255, 0), 2, 2, 8).unwrap();
|
||||
assert!(
|
||||
green.u[0] < 60 && green.v[0] < 60,
|
||||
"green U={} V={}",
|
||||
green.u[0],
|
||||
green.v[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra_plane_sizes() {
|
||||
let out = bgra_to_i420(&solid_bgra(8, 6, 10, 20, 30), 8, 6, 32).unwrap();
|
||||
assert_eq!(out.y.len(), 8 * 6);
|
||||
assert_eq!(out.u.len(), 4 * 3);
|
||||
assert_eq!(out.v.len(), 4 * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra_golden_2x2_solid_colour_exact_bytes() {
|
||||
let src = solid_bgra(2, 2, 32, 64, 128);
|
||||
let out = bgra_to_i420(&src, 2, 2, 8).unwrap();
|
||||
assert_eq!(out.width, 2);
|
||||
assert_eq!(out.height, 2);
|
||||
assert_eq!(out.y, vec![84, 84, 84, 84]);
|
||||
assert_eq!(out.u, vec![105]);
|
||||
assert_eq!(out.v, vec![158]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra_golden_strided_2x2_skips_row_padding() {
|
||||
let mut src = vec![0xFFu8; 16 * 2];
|
||||
for px in 0..2 {
|
||||
let o = px * 4;
|
||||
src[o..o + 4].copy_from_slice(&[0, 0, 0, 255]);
|
||||
}
|
||||
for px in 0..2 {
|
||||
let o = 16 + px * 4;
|
||||
src[o..o + 4].copy_from_slice(&[255, 255, 255, 255]);
|
||||
}
|
||||
let out = bgra_to_i420(&src, 2, 2, 16).unwrap();
|
||||
assert!(
|
||||
near(out.y[0], 16, 1) && near(out.y[1], 16, 1),
|
||||
"row0 Y={:?}",
|
||||
&out.y[0..2]
|
||||
);
|
||||
assert!(
|
||||
near(out.y[2], 235, 2) && near(out.y[3], 235, 2),
|
||||
"row1 Y={:?}",
|
||||
&out.y[2..4]
|
||||
);
|
||||
assert!(
|
||||
near(out.u[0], 128, 2) && near(out.v[0], 128, 2),
|
||||
"U={} V={}",
|
||||
out.u[0],
|
||||
out.v[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra_to_i420_planes_matches_tight_conversion_with_destination_padding() {
|
||||
let src = solid_bgra(4, 2, 32, 64, 128);
|
||||
let tight = bgra_to_i420(&src, 4, 2, 16).unwrap();
|
||||
let mut y = [0u8; 10];
|
||||
let mut u = [0u8; 4];
|
||||
let mut v = [0u8; 4];
|
||||
assert!(bgra_to_i420_planes(
|
||||
&src, 4, 2, 16, &mut y, &mut u, &mut v, 5, 2, 2
|
||||
));
|
||||
assert_eq!(&y[0..4], &tight.y[0..4]);
|
||||
assert_eq!(&y[5..9], &tight.y[4..8]);
|
||||
assert_eq!(&u[0..2], &tight.u[0..2]);
|
||||
assert_eq!(&v[0..2], &tight.v[0..2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_tight_i420_into_reuses_existing_plane_storage() {
|
||||
let mut dst = I420::new(4, 2).unwrap();
|
||||
let ptrs = (dst.y.as_ptr(), dst.u.as_ptr(), dst.v.as_ptr());
|
||||
let src: Vec<u8> = (0u8..12).collect();
|
||||
|
||||
assert_eq!(tight_i420_byte_len(4, 2), Some(12));
|
||||
assert!(copy_tight_i420_into(&src, 4, 2, &mut dst));
|
||||
|
||||
assert_eq!(dst.y.as_ptr(), ptrs.0);
|
||||
assert_eq!(dst.u.as_ptr(), ptrs.1);
|
||||
assert_eq!(dst.v.as_ptr(), ptrs.2);
|
||||
assert_eq!(dst.y, vec![0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
assert_eq!(dst.u, vec![8, 9]);
|
||||
assert_eq!(dst.v, vec![10, 11]);
|
||||
}
|
||||
|
||||
fn solid_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity((width * height * 3) as usize);
|
||||
for _ in 0..(width * height) {
|
||||
v.extend([r, g, b]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_rejects_odd_dims_and_short_buffer() {
|
||||
assert!(rgb_to_i420(&[0u8; 64], 3, 2).is_none());
|
||||
assert!(rgb_to_i420(&[0u8; 64], 2, 1).is_none());
|
||||
assert!(rgb_to_i420(&[0u8; 11], 2, 2).is_none());
|
||||
assert!(rgb_to_i420(&[0u8; 12], 2, 2).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_golden_2x2_solid_colour_matches_bgra_path() {
|
||||
let out = rgb_to_i420(&solid_rgb(2, 2, 128, 64, 32), 2, 2).unwrap();
|
||||
assert_eq!(out.width, 2);
|
||||
assert_eq!(out.height, 2);
|
||||
assert_eq!(out.y, vec![84, 84, 84, 84]);
|
||||
assert_eq!(out.u, vec![105]);
|
||||
assert_eq!(out.v, vec![158]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_plane_sizes_and_levels() {
|
||||
let black = rgb_to_i420(&solid_rgb(2, 2, 0, 0, 0), 2, 2).unwrap();
|
||||
assert!(near(black.y[0], 16, 1));
|
||||
assert!(near(black.u[0], 128, 1) && near(black.v[0], 128, 1));
|
||||
let white = rgb_to_i420(&solid_rgb(4, 4, 255, 255, 255), 4, 4).unwrap();
|
||||
assert_eq!(white.y.len(), 16);
|
||||
assert_eq!(white.u.len(), 4);
|
||||
assert_eq!(white.v.len(), 4);
|
||||
assert!(near(white.y[0], 235, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nv12_golden_4x2_packed_to_i420() {
|
||||
let mut src = Vec::new();
|
||||
src.extend(0u8..8);
|
||||
src.extend([40, 41, 42, 43]);
|
||||
let out = nv12_to_i420(&src, 4, 2, 4, 4).unwrap();
|
||||
assert_eq!(out.width, 4);
|
||||
assert_eq!(out.height, 2);
|
||||
assert_eq!(out.y, (0u8..8).collect::<Vec<_>>());
|
||||
assert_eq!(out.u, vec![40, 42]);
|
||||
assert_eq!(out.v, vec![41, 43]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user