diff --git a/.codex/skills/run-headless-simulator/SKILL.md b/.codex/skills/run-headless-simulator/SKILL.md index 9717e22..efde94b 100644 --- a/.codex/skills/run-headless-simulator/SKILL.md +++ b/.codex/skills/run-headless-simulator/SKILL.md @@ -179,17 +179,19 @@ Headless option names use kebab-case. Participant JSON fields use snake_case. | `--audio-enabled true\|false` | `"audio_enabled"` | | `--video-enabled true\|false` | `"video_enabled"` | | `--screenshare-enabled true\|false` | `"screenshare_enabled"` | -| `--auto-gain-control true\|false` | `"auto_gain_control"` | +| `--auto-gain-control true\|false\|default` | `"auto_gain_control"` | | `--noise-suppression MODEL` | `"noise_suppression"` | | `--transport MODE` | `"transport"` | | `--video-constraint-publish-webcam CONSTRAINT` | `"video_constraint_publish_webcam"` | | `--video-constraint-subscribe CONSTRAINT` | `"video_constraint_subscribe"` | | `--video-max-concurrent-tracks TRACKS` | `"video_max_concurrent_tracks"` | -| `--blur true\|false` | `"blur"` | +| `--blur true\|false\|default` | `"blur"` | Repeat `--participant JSON` to add participants. It has no matching JSON field. -Use a nonnegative integer for `--video-max-concurrent-tracks` or `video_max_concurrent_tracks`. JSON `null` inherits the shared limit. It does not remove that limit. +Auto gain control, background blur, both video constraints and the track limit default to `default`, which leaves the setting to hyper.video: an admin's value for the session, else hyper.video's default. JSON `null` inherits the shared value instead. + +Use a nonnegative integer, `unlimited` or `default` for `--video-max-concurrent-tracks` or `video_max_concurrent_tracks`. hyper.video's default is one track. ### Accepted Values @@ -207,6 +209,7 @@ Transport: Video constraint: +- `default` - `none` - `90p` - `144p` @@ -217,6 +220,7 @@ Video constraint: - `1080p` - `1440p` - `2160p` +- `4320p` Noise suppression: diff --git a/browser/src/participant/cloudflare/mod.rs b/browser/src/participant/cloudflare/mod.rs index 9338948..4e19efb 100644 --- a/browser/src/participant/cloudflare/mod.rs +++ b/browser/src/participant/cloudflare/mod.rs @@ -20,6 +20,8 @@ use client_simulator_config::{ CloudflareConfig, NoiseSuppression, TransportMode, + VideoConstraint, + VideoMaxConcurrentTracks, }; use cloudflare_worker_client::{ types, @@ -288,7 +290,7 @@ impl CloudflareSession { } ParticipantMessage::SetVideoMaxConcurrentTracks(value) => { types::SessionCommandRequest::SetVideoMaxConcurrentTracks { - video_max_concurrent_tracks: value.map(|value| value as u64), + video_max_concurrent_tracks: value.limit().map(|value| value as u64), } } ParticipantMessage::ToggleBackgroundBlur => types::SessionCommandRequest::ToggleBackgroundBlur, @@ -510,11 +512,13 @@ fn map_frontend_kind(frontend_kind: ResolvedFrontendKind) -> types::SessionCreat /// Maps the settings that [`CloudflareSession::normalized_settings`] returns onto the worker's. /// /// The worker's schema lacks `hyper-voice`, which `normalized_settings` replaces with `none`, so it maps to `none`. +/// The schema also has no way to leave a setting to hyper.video, so a setting that the participant leaves to +/// hyper.video sends hyper.video's default, and the track limit no limit, `null`. fn map_settings(settings: &crate::participant::shared::ParticipantSettings) -> types::ParticipantSettings { types::ParticipantSettings { audio_enabled: settings.audio_enabled, - auto_gain_control: settings.auto_gain_control, - blur: settings.blur, + auto_gain_control: settings.auto_gain_control.own().unwrap_or(true), + blur: settings.blur.own().unwrap_or(false), noise_suppression: match settings.noise_suppression { NoiseSuppression::Disabled | NoiseSuppression::HyperVoice => { types::ParticipantSettingsNoiseSuppression::None @@ -525,9 +529,23 @@ fn map_settings(settings: &crate::participant::shared::ParticipantSettings) -> t client_simulator_config::TransportMode::WebRTC => types::ParticipantSettingsTransport::Webrtc, client_simulator_config::TransportMode::WebTransport => types::ParticipantSettingsTransport::Webtransport, }, - video_constraint_publish_webcam: map_video_constraint_publish_webcam(settings.video_constraint_publish_webcam), - video_constraint_subscribe: map_video_constraint_subscribe(settings.video_constraint_subscribe), - video_max_concurrent_tracks: settings.video_max_concurrent_tracks.map(|value| value as u64), + video_constraint_publish_webcam: map_video_constraint_publish_webcam( + settings + .video_constraint_publish_webcam + .own() + .unwrap_or(VideoConstraint::None), + ), + video_constraint_subscribe: map_video_constraint_subscribe( + settings + .video_constraint_subscribe + .own() + .unwrap_or(VideoConstraint::None), + ), + video_max_concurrent_tracks: settings + .video_max_concurrent_tracks + .own() + .and_then(VideoMaxConcurrentTracks::limit) + .map(|value| value as u64), video_enabled: settings.video_enabled, } } @@ -550,7 +568,11 @@ fn map_state(participant_name: &str, state: &types::ParticipantState) -> Partici state.video_constraint_publish_webcam, ), video_constraint_subscribe: map_state_video_constraint_subscribe(state.video_constraint_subscribe), - video_max_concurrent_tracks: state.video_max_concurrent_tracks.map(|value| value as usize), + video_max_concurrent_tracks: state + .video_max_concurrent_tracks + .map_or(VideoMaxConcurrentTracks::Unlimited, |value| { + VideoMaxConcurrentTracks::Limit(value as usize) + }), background_blur: state.background_blur, screenshare_activated: state.screenshare_activated, } @@ -609,6 +631,10 @@ fn map_state_noise_suppression( } } +/// Maps the outgoing webcam constraint to the worker's. +/// +/// The worker's schema has no 4320p. hyper.video has no resolution level above it, so a 4320p limit leaves every +/// resolution, as `none` does. fn map_video_constraint_publish_webcam( value: client_simulator_config::VideoConstraint, ) -> types::ParticipantSettingsVideoConstraintPublishWebcam { @@ -629,9 +655,12 @@ fn map_video_constraint_publish_webcam( client_simulator_config::VideoConstraint::P2160 => { types::ParticipantSettingsVideoConstraintPublishWebcam::X2160p } + client_simulator_config::VideoConstraint::P4320 => types::ParticipantSettingsVideoConstraintPublishWebcam::None, } } +/// Maps the incoming video constraint to the worker's, 4320p to `none`, as [`map_video_constraint_publish_webcam`] +/// says. fn map_video_constraint_subscribe( value: client_simulator_config::VideoConstraint, ) -> types::ParticipantSettingsVideoConstraintSubscribe { @@ -646,6 +675,7 @@ fn map_video_constraint_subscribe( client_simulator_config::VideoConstraint::P1080 => types::ParticipantSettingsVideoConstraintSubscribe::X1080p, client_simulator_config::VideoConstraint::P1440 => types::ParticipantSettingsVideoConstraintSubscribe::X1440p, client_simulator_config::VideoConstraint::P2160 => types::ParticipantSettingsVideoConstraintSubscribe::X2160p, + client_simulator_config::VideoConstraint::P4320 => types::ParticipantSettingsVideoConstraintSubscribe::None, } } @@ -683,6 +713,8 @@ fn map_state_video_constraint_subscribe( } } +/// Maps the outgoing webcam constraint to the worker's command, 4320p to `none`, as +/// [`map_video_constraint_publish_webcam`] says. fn map_command_video_constraint_publish_webcam( value: client_simulator_config::VideoConstraint, ) -> types::SessionCommandRequestVideoConstraintPublishWebcam { @@ -715,9 +747,14 @@ fn map_command_video_constraint_publish_webcam( client_simulator_config::VideoConstraint::P2160 => { types::SessionCommandRequestVideoConstraintPublishWebcam::X2160p } + client_simulator_config::VideoConstraint::P4320 => { + types::SessionCommandRequestVideoConstraintPublishWebcam::None + } } } +/// Maps the incoming video constraint to the worker's command, 4320p to `none`, as +/// [`map_video_constraint_publish_webcam`] says. fn map_command_video_constraint_subscribe( value: client_simulator_config::VideoConstraint, ) -> types::SessionCommandRequestVideoConstraintSubscribe { @@ -732,6 +769,7 @@ fn map_command_video_constraint_subscribe( client_simulator_config::VideoConstraint::P1080 => types::SessionCommandRequestVideoConstraintSubscribe::X1080p, client_simulator_config::VideoConstraint::P1440 => types::SessionCommandRequestVideoConstraintSubscribe::X1440p, client_simulator_config::VideoConstraint::P2160 => types::SessionCommandRequestVideoConstraintSubscribe::X2160p, + client_simulator_config::VideoConstraint::P4320 => types::SessionCommandRequestVideoConstraintSubscribe::None, } } @@ -767,8 +805,10 @@ mod tests { media::FakeMedia, CloudflareConfig, NoiseSuppression, + OwnOrDefault, TransportMode, VideoConstraint, + VideoMaxConcurrentTracks, }; use serde_json::{ json, @@ -926,7 +966,7 @@ mod tests { assert_eq!(state.transport_mode, TransportMode::WebRTC); assert_eq!(state.video_constraint_publish_webcam, VideoConstraint::P720); assert_eq!(state.video_constraint_subscribe, VideoConstraint::None); - assert_eq!(state.video_max_concurrent_tracks, None); + assert_eq!(state.video_max_concurrent_tracks, VideoMaxConcurrentTracks::Unlimited); assert!(state.auto_gain_control); assert!(state.background_blur); @@ -1722,13 +1762,13 @@ mod tests { audio_enabled: true, video_enabled: true, screenshare_enabled: false, - auto_gain_control: true, + auto_gain_control: OwnOrDefault::Own(true), noise_suppression: NoiseSuppression::Disabled, transport: TransportMode::WebRTC, - video_constraint_publish_webcam: VideoConstraint::P720, - video_constraint_subscribe: VideoConstraint::None, - video_max_concurrent_tracks: None, - blur: true, + video_constraint_publish_webcam: OwnOrDefault::Own(VideoConstraint::P720), + video_constraint_subscribe: OwnOrDefault::Default, + video_max_concurrent_tracks: OwnOrDefault::Default, + blur: OwnOrDefault::Own(true), }, } } @@ -1781,7 +1821,7 @@ mod tests { transport_mode: TransportMode::WebRTC, video_constraint_publish_webcam, video_constraint_subscribe: VideoConstraint::None, - video_max_concurrent_tracks: None, + video_max_concurrent_tracks: VideoMaxConcurrentTracks::Unlimited, background_blur, screenshare_activated, } diff --git a/browser/src/participant/frontend/commands.rs b/browser/src/participant/frontend/commands.rs index 03746b7..1b011d8 100644 --- a/browser/src/participant/frontend/commands.rs +++ b/browser/src/participant/frontend/commands.rs @@ -1,34 +1,44 @@ use super::driver::BrowserDriver; use client_simulator_config::{ NoiseSuppression, + OwnOrDefault, VideoConstraint, + VideoMaxConcurrentTracks, }; use eyre::{ + eyre, Context as _, Result, }; +use serde::{ + de::DeserializeOwned, + Serialize, +}; +use serde_json::json; // Getters return a value via `return ;`. Setters mutate and ignore the result. -const NOISE_SUPPRESSION_GET: &str = "return hyper.settings.media.noiseSuppression;"; -const NOISE_SUPPRESSION_SET: &str = "hyper.settings.media.actions.setNoiseSuppression(arguments[0]);"; -const AUTO_GAIN_GET: &str = "return hyper.settings.media.autoGainControl;"; -const AUTO_GAIN_SET: &str = "hyper.settings.media.actions.setAutoGainControl(arguments[0]);"; -const BACKGROUND_BLUR_GET: &str = "return hyper.settings.media.backgroundBlur;"; -const BACKGROUND_BLUR_SET: &str = "hyper.settings.media.actions.setBackgroundBlur(arguments[0]);"; -const VIDEO_CONSTRAINT_PUBLISH_GET: &str = "return hyper.settings.media.videoConstraintPublishWebcam;"; -const VIDEO_CONSTRAINT_PUBLISH_SET: &str = - "hyper.settings.media.actions.setVideoConstraintPublishWebcam(arguments[0]);"; -const VIDEO_CONSTRAINT_SUBSCRIBE_GET: &str = "return hyper.settings.media.videoConstraintSubscribe;"; -const VIDEO_CONSTRAINT_SUBSCRIBE_SET: &str = "hyper.settings.media.actions.setVideoConstraintSubscribe(arguments[0]);"; -const VIDEO_MAX_CONCURRENT_TRACKS_GET: &str = "return hyper.settings.media.videoMaxConcurrentTracks;"; -const VIDEO_MAX_CONCURRENT_TRACKS_SET: &str = "hyper.settings.media.actions.setVideoMaxConcurrentTracks(arguments[0]);"; +// +// `hyper.settings.client` holds hyper.video's report of the client's settings and edits the layers that the client +// owns. Getters read the value that applies from the report, which is null until the core first reports. Setters edit +// the participant layer, where a participant keeps the settings it chooses for itself. The SDK queues an edit made +// before its runtime starts and sends it once the runtime is up. +const SETTING_GET: &str = "return hyper.settings.client.report.effective[arguments[0]];"; +const PARTICIPANT_EDIT: &str = "hyper.settings.client.edit('participant', arguments[0]);"; +// Turns volume normalization on with the participant's own levels, else the default ones, as the web app's +// "Automatically adjust volume" switch does. +const VOLUME_NORMALIZATION_ON: &str = "const { report, edit } = hyper.settings.client; \ + const levels = report.participant.audioSendVolumeNormalization ?? report.default.audioSendVolumeNormalization; \ + edit('participant', { set: { audioSendVolumeNormalization: levels } });"; const FORCE_WEBRTC_GET: &str = "return hyper.settings.sessionDebug.forceWebrtc;"; const FORCE_WEBRTC_SET: &str = "hyper.settings.sessionDebug.actions.setForceWebrtc(arguments[0]);"; -async fn get_string(driver: &dyn BrowserDriver, js: &str) -> Result { - let value = driver.eval(js, None).await?; - serde_json::from_value(value).context("failed to read string from eval result") -} +// The `SettingKey`s of the settings that the simulator reads and sets. +const NOISE_SUPPRESSION: &str = "audioSendNoiseSuppression"; +const VOLUME_NORMALIZATION: &str = "audioSendVolumeNormalization"; +const BACKGROUND_BLUR: &str = "videoSendWebcamBackgroundBlur"; +const WEBCAM_MAX_RESOLUTION_LEVEL: &str = "videoSendWebcamMaxResolutionLevel"; +const RECEIVE_MAX_RESOLUTION_LEVEL: &str = "videoReceiveMaxResolutionLevel"; +const MAX_CONCURRENT_TRACKS: &str = "videoSendMaxConcurrentTracks"; async fn get_bool(driver: &dyn BrowserDriver, js: &str) -> Result { let value = driver.eval(js, None).await?; @@ -41,68 +51,127 @@ async fn set_value(driver: &dyn BrowserDriver, js: &str, va Ok(()) } +/// Reads the value of the setting `key` that applies. +async fn get_setting(driver: &dyn BrowserDriver, key: &str) -> Result { + let value = driver.eval(SETTING_GET, Some(json!(key))).await?; + serde_json::from_value(value).with_context(|| format!("failed to read setting {key} from eval result")) +} + +/// Sets the setting `key` to `value` in the participant layer. +async fn set_setting(driver: &dyn BrowserDriver, key: &str, value: impl Serialize) -> Result<()> { + let value = serde_json::to_value(value).context("failed to serialize setting value")?; + driver + .eval(PARTICIPANT_EDIT, Some(json!({ "set": { key: value } }))) + .await?; + Ok(()) +} + +/// Unsets the setting `key` in the participant layer, so that the layers below it decide, by default hyper.video's +/// default. +async fn unset_setting(driver: &dyn BrowserDriver, key: &str) -> Result<()> { + driver.eval(PARTICIPANT_EDIT, Some(json!({ "unset": [key] }))).await?; + Ok(()) +} + +/// Sets the setting `key` to the participant's own value, or unsets it for [`OwnOrDefault::Default`]. +async fn edit_setting(driver: &dyn BrowserDriver, key: &str, value: OwnOrDefault) -> Result<()> { + match value { + OwnOrDefault::Own(value) => set_setting(driver, key, value).await, + OwnOrDefault::Default => unset_setting(driver, key).await, + } +} + +async fn get_video_constraint(driver: &dyn BrowserDriver, key: &str) -> Result { + let level: Option = get_setting(driver, key).await?; + VideoConstraint::from_resolution_level(level.as_deref()) + .ok_or_else(|| eyre!("no video constraint for resolution level {level:?}")) +} + pub(super) async fn get_noise_suppression(driver: &dyn BrowserDriver) -> Result { - get_string(driver, NOISE_SUPPRESSION_GET) - .await? - .parse::() - .context("failed to parse NoiseSuppression from string") + get_setting(driver, NOISE_SUPPRESSION).await } pub(super) async fn set_noise_suppression(driver: &dyn BrowserDriver, value: NoiseSuppression) -> Result<()> { - set_value(driver, NOISE_SUPPRESSION_SET, value.to_string()).await + set_setting(driver, NOISE_SUPPRESSION, value).await } +/// Whether volume normalization is on, which the web app's "Automatically adjust volume" switch turns on and off. pub(super) async fn get_auto_gain_control(driver: &dyn BrowserDriver) -> Result { - get_bool(driver, AUTO_GAIN_GET).await + let levels: Option = get_setting(driver, VOLUME_NORMALIZATION).await?; + Ok(levels.is_some()) } -pub(super) async fn set_auto_gain_control(driver: &dyn BrowserDriver, value: bool) -> Result<()> { - set_value(driver, AUTO_GAIN_SET, value).await +/// Turns volume normalization on or off, as [`get_auto_gain_control`] reads it, or unsets it for +/// [`OwnOrDefault::Default`]. +pub(super) async fn set_auto_gain_control(driver: &dyn BrowserDriver, value: OwnOrDefault) -> Result<()> { + match value { + OwnOrDefault::Own(true) => { + driver.eval(VOLUME_NORMALIZATION_ON, None).await?; + Ok(()) + } + OwnOrDefault::Own(false) => set_setting(driver, VOLUME_NORMALIZATION, serde_json::Value::Null).await, + OwnOrDefault::Default => unset_setting(driver, VOLUME_NORMALIZATION).await, + } } pub(super) async fn get_background_blur(driver: &dyn BrowserDriver) -> Result { - get_bool(driver, BACKGROUND_BLUR_GET).await + get_setting(driver, BACKGROUND_BLUR).await } -pub(super) async fn set_background_blur(driver: &dyn BrowserDriver, value: bool) -> Result<()> { - set_value(driver, BACKGROUND_BLUR_SET, value).await +pub(super) async fn set_background_blur(driver: &dyn BrowserDriver, value: OwnOrDefault) -> Result<()> { + edit_setting(driver, BACKGROUND_BLUR, value).await } pub(super) async fn get_video_constraint_publish_webcam(driver: &dyn BrowserDriver) -> Result { - get_string(driver, VIDEO_CONSTRAINT_PUBLISH_GET) - .await? - .parse::() - .context("failed to parse videoConstraintPublishWebcam from string") + get_video_constraint(driver, WEBCAM_MAX_RESOLUTION_LEVEL).await } pub(super) async fn set_video_constraint_publish_webcam( driver: &dyn BrowserDriver, - value: VideoConstraint, + value: OwnOrDefault, ) -> Result<()> { - set_value(driver, VIDEO_CONSTRAINT_PUBLISH_SET, value.to_string()).await + edit_setting( + driver, + WEBCAM_MAX_RESOLUTION_LEVEL, + value.map(VideoConstraint::resolution_level), + ) + .await } pub(super) async fn get_video_constraint_subscribe(driver: &dyn BrowserDriver) -> Result { - get_string(driver, VIDEO_CONSTRAINT_SUBSCRIBE_GET) - .await? - .parse::() - .context("failed to parse videoConstraintSubscribe from string") + get_video_constraint(driver, RECEIVE_MAX_RESOLUTION_LEVEL).await } -pub(super) async fn set_video_constraint_subscribe(driver: &dyn BrowserDriver, value: VideoConstraint) -> Result<()> { - set_value(driver, VIDEO_CONSTRAINT_SUBSCRIBE_SET, value.to_string()).await +pub(super) async fn set_video_constraint_subscribe( + driver: &dyn BrowserDriver, + value: OwnOrDefault, +) -> Result<()> { + edit_setting( + driver, + RECEIVE_MAX_RESOLUTION_LEVEL, + value.map(VideoConstraint::resolution_level), + ) + .await } -pub(super) async fn get_video_max_concurrent_tracks(driver: &dyn BrowserDriver) -> Result> { +pub(super) async fn get_video_max_concurrent_tracks(driver: &dyn BrowserDriver) -> Result { // Read as a nullable float to tolerate the page returning e.g. `2.0`, then cast. - let value = driver.eval(VIDEO_MAX_CONCURRENT_TRACKS_GET, None).await?; - let as_f64: Option = - serde_json::from_value(value).context("failed to read videoMaxConcurrentTracks from eval result")?; - Ok(as_f64.map(|value| value as usize)) + let as_f64: Option = get_setting(driver, MAX_CONCURRENT_TRACKS).await?; + Ok(as_f64.map_or(VideoMaxConcurrentTracks::Unlimited, |value| { + VideoMaxConcurrentTracks::Limit(value as usize) + })) } -pub(super) async fn set_video_max_concurrent_tracks(driver: &dyn BrowserDriver, value: Option) -> Result<()> { - set_value(driver, VIDEO_MAX_CONCURRENT_TRACKS_SET, value).await +pub(super) async fn set_video_max_concurrent_tracks( + driver: &dyn BrowserDriver, + value: OwnOrDefault, +) -> Result<()> { + edit_setting( + driver, + MAX_CONCURRENT_TRACKS, + value.map(VideoMaxConcurrentTracks::limit), + ) + .await } pub(super) async fn get_force_webrtc(driver: &dyn BrowserDriver) -> Result { @@ -122,7 +191,6 @@ pub(in crate::participant::frontend) mod tests { future::BoxFuture, FutureExt as _, }; - use serde_json::json; use std::{ sync::Mutex, time::Duration, @@ -183,45 +251,57 @@ pub(in crate::participant::frontend) mod tests { } } + fn participant_edit(set: serde_json::Value) -> (String, Option) { + (PARTICIPANT_EDIT.to_string(), Some(json!({ "set": set }))) + } + + fn participant_unset(key: &str) -> (String, Option) { + (PARTICIPANT_EDIT.to_string(), Some(json!({ "unset": [key] }))) + } + #[tokio::test] - async fn sets_video_constraints_through_media_settings_api() { + async fn sets_video_settings_in_participant_layer() { let driver = RecordingDriver::default(); - set_video_constraint_publish_webcam(&driver, VideoConstraint::P480) + set_video_constraint_publish_webcam(&driver, OwnOrDefault::Own(VideoConstraint::P480)) .await .unwrap(); - set_video_constraint_subscribe(&driver, VideoConstraint::P720) + set_video_constraint_subscribe(&driver, OwnOrDefault::Own(VideoConstraint::None)) .await .unwrap(); - set_video_max_concurrent_tracks(&driver, Some(2)).await.unwrap(); - set_video_max_concurrent_tracks(&driver, None).await.unwrap(); + set_video_constraint_subscribe(&driver, OwnOrDefault::Default) + .await + .unwrap(); + set_video_max_concurrent_tracks(&driver, OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2))) + .await + .unwrap(); + set_video_max_concurrent_tracks(&driver, OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited)) + .await + .unwrap(); + set_video_max_concurrent_tracks(&driver, OwnOrDefault::Default) + .await + .unwrap(); + set_background_blur(&driver, OwnOrDefault::Own(true)).await.unwrap(); + set_background_blur(&driver, OwnOrDefault::Default).await.unwrap(); assert_eq!( driver.calls(), vec![ - ( - "hyper.settings.media.actions.setVideoConstraintPublishWebcam(arguments[0]);".to_string(), - Some(json!("480p")), - ), - ( - "hyper.settings.media.actions.setVideoConstraintSubscribe(arguments[0]);".to_string(), - Some(json!("720p")), - ), - ( - "hyper.settings.media.actions.setVideoMaxConcurrentTracks(arguments[0]);".to_string(), - Some(json!(2)), - ), - ( - "hyper.settings.media.actions.setVideoMaxConcurrentTracks(arguments[0]);".to_string(), - Some(serde_json::Value::Null), - ), + participant_edit(json!({ "videoSendWebcamMaxResolutionLevel": "P480" })), + participant_edit(json!({ "videoReceiveMaxResolutionLevel": null })), + participant_unset("videoReceiveMaxResolutionLevel"), + participant_edit(json!({ "videoSendMaxConcurrentTracks": 2 })), + participant_edit(json!({ "videoSendMaxConcurrentTracks": null })), + participant_unset("videoSendMaxConcurrentTracks"), + participant_edit(json!({ "videoSendWebcamBackgroundBlur": true })), + participant_unset("videoSendWebcamBackgroundBlur"), ], ); } #[tokio::test] - async fn gets_video_constraints_from_media_settings_api() { - let driver = RecordingDriver::with_result(json!("360p")); + async fn reads_video_constraints_from_settings_report() { + let driver = RecordingDriver::with_result(json!("P360")); let value = get_video_constraint_publish_webcam(&driver).await.unwrap(); @@ -229,10 +309,46 @@ pub(in crate::participant::frontend) mod tests { assert_eq!( driver.calls(), vec![( - "return hyper.settings.media.videoConstraintPublishWebcam;".to_string(), - None, + SETTING_GET.to_string(), + Some(json!("videoSendWebcamMaxResolutionLevel")) )], ); + + let driver = RecordingDriver::with_result(serde_json::Value::Null); + + let value = get_video_constraint_subscribe(&driver).await.unwrap(); + + assert_eq!(value, VideoConstraint::None); + assert_eq!( + driver.calls(), + vec![(SETTING_GET.to_string(), Some(json!("videoReceiveMaxResolutionLevel")))], + ); + } + + #[tokio::test] + async fn auto_gain_control_switches_volume_normalization() { + let driver = RecordingDriver::default(); + + set_auto_gain_control(&driver, OwnOrDefault::Own(false)).await.unwrap(); + set_auto_gain_control(&driver, OwnOrDefault::Default).await.unwrap(); + + assert_eq!( + driver.calls(), + vec![ + participant_edit(json!({ "audioSendVolumeNormalization": null })), + participant_unset("audioSendVolumeNormalization"), + ], + ); + + let levels = json!({ "targetDbfs": 0.0, "thresholdDbfs": -38.0 }); + assert!(get_auto_gain_control(&RecordingDriver::with_result(levels)) + .await + .unwrap()); + assert!( + !get_auto_gain_control(&RecordingDriver::with_result(serde_json::Value::Null)) + .await + .unwrap() + ); } #[tokio::test] @@ -241,13 +357,10 @@ pub(in crate::participant::frontend) mod tests { let value = get_video_max_concurrent_tracks(&driver).await.unwrap(); - assert_eq!(value, None); + assert_eq!(value, VideoMaxConcurrentTracks::Unlimited); assert_eq!( driver.calls(), - vec![( - "return hyper.settings.media.videoMaxConcurrentTracks;".to_string(), - None, - )], + vec![(SETTING_GET.to_string(), Some(json!("videoSendMaxConcurrentTracks")))], ); } @@ -258,6 +371,6 @@ pub(in crate::participant::frontend) mod tests { let value = get_video_max_concurrent_tracks(&driver).await.unwrap(); - assert_eq!(value, Some(2)); + assert_eq!(value, VideoMaxConcurrentTracks::Limit(2)); } } diff --git a/browser/src/participant/frontend/core.rs b/browser/src/participant/frontend/core.rs index 2efcf6b..bc903fc 100644 --- a/browser/src/participant/frontend/core.rs +++ b/browser/src/participant/frontend/core.rs @@ -31,8 +31,10 @@ use super::{ use crate::auth::BorrowedCredentials; use client_simulator_config::{ NoiseSuppression, + OwnOrDefault, TransportMode, VideoConstraint, + VideoMaxConcurrentTracks, }; use eyre::{ Context as _, @@ -109,12 +111,7 @@ impl ParticipantInner { .wait_for(classic::JOIN_BUTTON, Duration::from_secs(30)) .await?; - if let Err(err) = self.apply_all_settings(true).await { - error!( - participant = %self.participant_name(), - "Failed to apply settings before joining: {err}" - ); - } + self.apply_all_settings(true).await; self.context .driver @@ -135,45 +132,59 @@ impl ParticipantInner { Ok(()) } - async fn apply_all_settings(&self, in_lobby: bool) -> Result<()> { + /// Applies the participant's launch settings, each on its own. + /// + /// A step that fails is logged, and the steps after it still apply. So on a deployment of hyper.video without + /// `hyper.settings.client`, where every settings edit fails, the transport and the microphone and camera state + /// still apply. + async fn apply_all_settings(&self, in_lobby: bool) { let settings = &self.context.launch_spec.settings; let driver = self.context.driver.as_ref(); - set_auto_gain_control(driver, settings.auto_gain_control) - .await - .context("failed to set auto gain control")?; - set_noise_suppression(driver, settings.noise_suppression) - .await - .context("failed to set noise suppression")?; - set_background_blur(driver, settings.blur) - .await - .context("failed to set background blur")?; - set_video_constraint_publish_webcam(driver, settings.video_constraint_publish_webcam) - .await - .context("failed to set outgoing webcam video constraint")?; - set_video_constraint_subscribe(driver, settings.video_constraint_subscribe) - .await - .context("failed to set incoming video constraint")?; - set_video_max_concurrent_tracks(driver, settings.video_max_concurrent_tracks) - .await - .context("failed to set max concurrent video tracks")?; - set_force_webrtc(driver, settings.transport == TransportMode::WebRTC) - .await - .context("failed to set transport mode")?; + self.log_failure( + "auto gain control", + set_auto_gain_control(driver, settings.auto_gain_control).await, + ); + self.log_failure( + "noise suppression", + set_noise_suppression(driver, settings.noise_suppression).await, + ); + self.log_failure("background blur", set_background_blur(driver, settings.blur).await); + self.log_failure( + "outgoing webcam video constraint", + set_video_constraint_publish_webcam(driver, settings.video_constraint_publish_webcam).await, + ); + self.log_failure( + "incoming video constraint", + set_video_constraint_subscribe(driver, settings.video_constraint_subscribe).await, + ); + self.log_failure( + "max concurrent video tracks", + set_video_max_concurrent_tracks(driver, settings.video_max_concurrent_tracks).await, + ); + self.log_failure( + "transport mode", + set_force_webrtc(driver, settings.transport == TransportMode::WebRTC).await, + ); if !settings.audio_enabled { - self.toggle_audio_inner().await?; + self.log_failure("muted microphone", self.toggle_audio_inner().await); } if !settings.video_enabled { - self.toggle_video_inner().await?; + self.log_failure("disabled camera", self.toggle_video_inner().await); } if !in_lobby && settings.screenshare_enabled { - self.toggle_screen_share_inner().await?; + self.log_failure("screen share", self.toggle_screen_share_inner().await); } + } - Ok(()) + /// Logs that the launch setting `setting` failed to apply. + fn log_failure(&self, setting: &str, result: Result<()>) { + if let Err(err) = result { + error!(participant = %self.participant_name(), "Failed to apply {setting}: {err:#}"); + } } async fn leave_session(&mut self) -> Result<()> { @@ -223,21 +234,21 @@ impl ParticipantInner { async fn set_video_constraint_publish_webcam_inner(&self, value: VideoConstraint) -> Result<()> { info!(participant = %self.participant_name(), "Changing outgoing webcam video constraint to {value}"); - set_video_constraint_publish_webcam(self.context.driver.as_ref(), value) + set_video_constraint_publish_webcam(self.context.driver.as_ref(), OwnOrDefault::Own(value)) .await .context("Failed to set outgoing webcam video constraint") } async fn set_video_constraint_subscribe_inner(&self, value: VideoConstraint) -> Result<()> { info!(participant = %self.participant_name(), "Changing incoming video constraint to {value}"); - set_video_constraint_subscribe(self.context.driver.as_ref(), value) + set_video_constraint_subscribe(self.context.driver.as_ref(), OwnOrDefault::Own(value)) .await .context("Failed to set incoming video constraint") } - async fn set_video_max_concurrent_tracks_inner(&self, value: Option) -> Result<()> { - info!(participant = %self.participant_name(), ?value, "Changing max concurrent video tracks"); - set_video_max_concurrent_tracks(self.context.driver.as_ref(), value) + async fn set_video_max_concurrent_tracks_inner(&self, value: VideoMaxConcurrentTracks) -> Result<()> { + info!(participant = %self.participant_name(), %value, "Changing max concurrent video tracks"); + set_video_max_concurrent_tracks(self.context.driver.as_ref(), OwnOrDefault::Own(value)) .await .context("Failed to set max concurrent video tracks") } @@ -256,7 +267,7 @@ impl ParticipantInner { async fn toggle_auto_gain_control_inner(&self) -> Result<()> { let driver = self.context.driver.as_ref(); let auto_gain_control = get_auto_gain_control(driver).await?; - set_auto_gain_control(driver, !auto_gain_control) + set_auto_gain_control(driver, OwnOrDefault::Own(!auto_gain_control)) .await .context("Failed to set auto gain control")?; Ok(()) @@ -265,7 +276,7 @@ impl ParticipantInner { async fn toggle_background_blur_inner(&self) -> Result<()> { let driver = self.context.driver.as_ref(); let background_blur = get_background_blur(driver).await?; - set_background_blur(driver, !background_blur) + set_background_blur(driver, OwnOrDefault::Own(!background_blur)) .await .context("Failed to set background blur")?; Ok(()) diff --git a/browser/src/participant/frontend/lite.rs b/browser/src/participant/frontend/lite.rs index 4a104a2..bc22322 100644 --- a/browser/src/participant/frontend/lite.rs +++ b/browser/src/participant/frontend/lite.rs @@ -17,6 +17,7 @@ use client_simulator_config::{ NoiseSuppression, TransportMode, VideoConstraint, + VideoMaxConcurrentTracks, }; use eyre::{ Context as _, @@ -266,7 +267,7 @@ impl ParticipantInnerLite { Ok(()) } - async fn set_video_max_concurrent_tracks_inner(&self, _value: Option) -> Result<()> { + async fn set_video_max_concurrent_tracks_inner(&self, _value: VideoMaxConcurrentTracks) -> Result<()> { self.log_unsupported("Video constraint"); Ok(()) } @@ -337,11 +338,19 @@ impl ParticipantInnerLite { username: self.context.launch_spec.username.clone(), running: true, joined, - auto_gain_control: self.context.launch_spec.settings.auto_gain_control, + // Lite neither applies nor reads volume normalization, so the state reports the configured value, and + // hyper.video's default, on, for none. + auto_gain_control: self + .context + .launch_spec + .settings + .auto_gain_control + .own() + .unwrap_or(true), transport_mode: TransportMode::default(), video_constraint_publish_webcam: VideoConstraint::default(), video_constraint_subscribe: VideoConstraint::default(), - video_max_concurrent_tracks: None, + video_max_concurrent_tracks: VideoMaxConcurrentTracks::default(), noise_suppression: NoiseSuppression::Disabled, muted: !self.context.launch_spec.settings.audio_enabled, video_activated: self.context.launch_spec.settings.video_enabled, diff --git a/browser/src/participant/mod.rs b/browser/src/participant/mod.rs index eb18f0d..4fdd104 100644 --- a/browser/src/participant/mod.rs +++ b/browser/src/participant/mod.rs @@ -457,7 +457,7 @@ impl Participant { self.send_message(ParticipantMessage::SetVideoConstraintSubscribe(value)); } - pub fn set_video_max_concurrent_tracks(&self, value: Option) { + pub fn set_video_max_concurrent_tracks(&self, value: client_simulator_config::VideoMaxConcurrentTracks) { self.send_message(ParticipantMessage::SetVideoMaxConcurrentTracks(value)); } diff --git a/browser/src/participant/remote_stub.rs b/browser/src/participant/remote_stub.rs index 5cbc903..9dd854d 100644 --- a/browser/src/participant/remote_stub.rs +++ b/browser/src/participant/remote_stub.rs @@ -8,6 +8,10 @@ use crate::participant::shared::{ ParticipantLaunchSpec, ParticipantState, }; +use client_simulator_config::{ + VideoConstraint, + VideoMaxConcurrentTracks, +}; use eyre::Result; use futures::{ future::BoxFuture, @@ -43,6 +47,8 @@ impl ParticipantDriverSession for RemoteStubSession { fn start(&mut self) -> BoxFuture<'_, Result<()>> { async move { + // The stub applies none of hyper.video's settings, so a setting that the participant leaves to hyper.video + // reports hyper.video's default, and the track limit no limit. self.state = ParticipantState { username: self.launch_spec.username.clone(), running: true, @@ -50,13 +56,28 @@ impl ParticipantDriverSession for RemoteStubSession { joined: true, muted: !self.launch_spec.settings.audio_enabled, video_activated: self.launch_spec.settings.video_enabled, - auto_gain_control: self.launch_spec.settings.auto_gain_control, + auto_gain_control: self.launch_spec.settings.auto_gain_control.own().unwrap_or(true), noise_suppression: self.launch_spec.settings.noise_suppression, transport_mode: self.launch_spec.settings.transport, - video_constraint_publish_webcam: self.launch_spec.settings.video_constraint_publish_webcam, - video_constraint_subscribe: self.launch_spec.settings.video_constraint_subscribe, - video_max_concurrent_tracks: self.launch_spec.settings.video_max_concurrent_tracks, - background_blur: self.launch_spec.settings.blur, + video_constraint_publish_webcam: self + .launch_spec + .settings + .video_constraint_publish_webcam + .own() + .unwrap_or(VideoConstraint::None), + video_constraint_subscribe: self + .launch_spec + .settings + .video_constraint_subscribe + .own() + .unwrap_or(VideoConstraint::None), + video_max_concurrent_tracks: self + .launch_spec + .settings + .video_max_concurrent_tracks + .own() + .unwrap_or(VideoMaxConcurrentTracks::Unlimited), + background_blur: self.launch_spec.settings.blur.own().unwrap_or(false), screenshare_activated: self.launch_spec.settings.screenshare_enabled, }; @@ -114,7 +135,7 @@ impl ParticipantDriverSession for RemoteStubSession { self.state.video_max_concurrent_tracks = value; self.log_message( "debug", - format!("remote stub set max concurrent video tracks to {value:?}"), + format!("remote stub set max concurrent video tracks to {value}"), ); } ParticipantMessage::ToggleBackgroundBlur => { diff --git a/browser/src/participant/shared/messages.rs b/browser/src/participant/shared/messages.rs index f9abbf9..6739010 100644 --- a/browser/src/participant/shared/messages.rs +++ b/browser/src/participant/shared/messages.rs @@ -1,6 +1,7 @@ use client_simulator_config::{ NoiseSuppression, VideoConstraint, + VideoMaxConcurrentTracks, }; use std::fmt; @@ -16,7 +17,7 @@ pub enum ParticipantMessage { SetNoiseSuppression(NoiseSuppression), SetVideoConstraintPublishWebcam(VideoConstraint), SetVideoConstraintSubscribe(VideoConstraint), - SetVideoMaxConcurrentTracks(Option), + SetVideoMaxConcurrentTracks(VideoMaxConcurrentTracks), ToggleBackgroundBlur, } diff --git a/browser/src/participant/shared/spec.rs b/browser/src/participant/shared/spec.rs index 0890640..9dec04a 100644 --- a/browser/src/participant/shared/spec.rs +++ b/browser/src/participant/shared/spec.rs @@ -1,8 +1,10 @@ use client_simulator_config::{ NoiseSuppression, + OwnOrDefault, ParticipantConfig, TransportMode, VideoConstraint, + VideoMaxConcurrentTracks, }; use url::Url; @@ -28,13 +30,13 @@ pub(in crate::participant) struct ParticipantSettings { pub(in crate::participant) audio_enabled: bool, pub(in crate::participant) video_enabled: bool, pub(in crate::participant) screenshare_enabled: bool, - pub(in crate::participant) auto_gain_control: bool, + pub(in crate::participant) auto_gain_control: OwnOrDefault, pub(in crate::participant) noise_suppression: NoiseSuppression, pub(in crate::participant) transport: TransportMode, - pub(in crate::participant) video_constraint_publish_webcam: VideoConstraint, - pub(in crate::participant) video_constraint_subscribe: VideoConstraint, - pub(in crate::participant) video_max_concurrent_tracks: Option, - pub(in crate::participant) blur: bool, + pub(in crate::participant) video_constraint_publish_webcam: OwnOrDefault, + pub(in crate::participant) video_constraint_subscribe: OwnOrDefault, + pub(in crate::participant) video_max_concurrent_tracks: OwnOrDefault, + pub(in crate::participant) blur: OwnOrDefault, } impl From<&ParticipantConfig> for ParticipantSettings { @@ -91,9 +93,11 @@ mod tests { use client_simulator_config::{ Config, NoiseSuppression, + OwnOrDefault, ParticipantConfig, TransportMode, VideoConstraint, + VideoMaxConcurrentTracks, }; use url::Url; @@ -106,13 +110,13 @@ mod tests { audio_enabled: true, video_enabled: false, screenshare_enabled: true, - auto_gain_control: true, + auto_gain_control: OwnOrDefault::Own(true), noise_suppression: NoiseSuppression::HyperVoice, transport: TransportMode::WebRTC, - video_constraint_publish_webcam: VideoConstraint::P480, - video_constraint_subscribe: VideoConstraint::P720, - video_max_concurrent_tracks: Some(2), - blur: true, + video_constraint_publish_webcam: OwnOrDefault::Own(VideoConstraint::P480), + video_constraint_subscribe: OwnOrDefault::Default, + video_max_concurrent_tracks: OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)), + blur: OwnOrDefault::Own(true), ..Default::default() }, }; @@ -125,12 +129,18 @@ mod tests { assert!(spec.settings.audio_enabled); assert!(!spec.settings.video_enabled); assert!(spec.settings.screenshare_enabled); - assert!(spec.settings.auto_gain_control); + assert_eq!(spec.settings.auto_gain_control, OwnOrDefault::Own(true)); assert_eq!(spec.settings.noise_suppression, NoiseSuppression::HyperVoice); assert_eq!(spec.settings.transport, TransportMode::WebRTC); - assert_eq!(spec.settings.video_constraint_publish_webcam, VideoConstraint::P480); - assert_eq!(spec.settings.video_constraint_subscribe, VideoConstraint::P720); - assert_eq!(spec.settings.video_max_concurrent_tracks, Some(2)); - assert!(spec.settings.blur); + assert_eq!( + spec.settings.video_constraint_publish_webcam, + OwnOrDefault::Own(VideoConstraint::P480) + ); + assert_eq!(spec.settings.video_constraint_subscribe, OwnOrDefault::Default); + assert_eq!( + spec.settings.video_max_concurrent_tracks, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)) + ); + assert_eq!(spec.settings.blur, OwnOrDefault::Own(true)); } } diff --git a/browser/src/participant/shared/state.rs b/browser/src/participant/shared/state.rs index 7f225c8..06de6a6 100644 --- a/browser/src/participant/shared/state.rs +++ b/browser/src/participant/shared/state.rs @@ -2,6 +2,7 @@ use client_simulator_config::{ NoiseSuppression, TransportMode, VideoConstraint, + VideoMaxConcurrentTracks, }; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -32,7 +33,7 @@ pub struct ParticipantState { pub transport_mode: TransportMode, pub video_constraint_publish_webcam: VideoConstraint, pub video_constraint_subscribe: VideoConstraint, - pub video_max_concurrent_tracks: Option, + pub video_max_concurrent_tracks: VideoMaxConcurrentTracks, pub background_blur: bool, pub screenshare_activated: bool, } diff --git a/config/src/client_config.rs b/config/src/client_config.rs index fb8c30b..498826d 100644 --- a/config/src/client_config.rs +++ b/config/src/client_config.rs @@ -1,13 +1,145 @@ use serde::{ + de::{ + self, + IntoDeserializer as _, + }, Deserialize, + Deserializer, Serialize, + Serializer, +}; +use std::{ + fmt, + marker::PhantomData, + str::FromStr, }; use strum::{ Display, EnumIter, EnumString, + IntoEnumIterator as _, }; +/// A participant's own value of one of hyper.video's settings, or none, so that hyper.video decides. +/// +/// A Hyper Core participant sets its own value in the participant layer of hyper.video's settings, and unsets the +/// setting there for [`Self::Default`], so that an admin's value for the session applies, else hyper.video's default. +/// The CLI, the config and `--participant` JSON spell [`Self::Default`] as `default`, and an own value as `T` does. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum OwnOrDefault { + /// No value of the participant's own. + #[default] + Default, + /// The participant's own value. + Own(T), +} + +impl OwnOrDefault { + const DEFAULT: &str = "default"; + + /// The participant's own value, `None` for [`Self::Default`]. + pub fn own(self) -> Option { + match self { + Self::Default => None, + Self::Own(value) => Some(value), + } + } + + /// The setting with `f` applied to the participant's own value. + pub fn map(self, f: impl FnOnce(T) -> U) -> OwnOrDefault { + match self { + Self::Default => OwnOrDefault::Default, + Self::Own(value) => OwnOrDefault::Own(f(value)), + } + } +} + +impl fmt::Display for OwnOrDefault { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Default => f.write_str(Self::DEFAULT), + Self::Own(value) => value.fmt(f), + } + } +} + +impl FromStr for OwnOrDefault +where + T: FromStr, + T::Err: fmt::Display, +{ + type Err = String; + + fn from_str(value: &str) -> Result { + if value == Self::DEFAULT { + return Ok(Self::Default); + } + value + .parse() + .map(Self::Own) + .map_err(|error| format!("neither `{}` nor a value of the setting: {error}", Self::DEFAULT)) + } +} + +impl Serialize for OwnOrDefault { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Default => serializer.serialize_str(Self::DEFAULT), + Self::Own(value) => value.serialize(serializer), + } + } +} + +/// Reads `default`, and the `null` of an empty config key, as [`OwnOrDefault::Default`], another string as +/// [`OwnOrDefault`]'s [`FromStr`] does, and a bool or a number as `T` reads it. +impl<'de, T> Deserialize<'de> for OwnOrDefault +where + T: Deserialize<'de> + FromStr, + T::Err: fmt::Display, +{ + fn deserialize>(deserializer: D) -> Result { + struct Visitor(PhantomData); + + impl<'de, T> de::Visitor<'de> for Visitor + where + T: Deserialize<'de> + FromStr, + T::Err: fmt::Display, + { + type Value = OwnOrDefault; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "`{}` or a value of the setting", OwnOrDefault::::DEFAULT) + } + + fn visit_unit(self) -> Result { + Ok(OwnOrDefault::Default) + } + + fn visit_none(self) -> Result { + Ok(OwnOrDefault::Default) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(E::custom) + } + + fn visit_bool(self, value: bool) -> Result { + T::deserialize(value.into_deserializer()).map(OwnOrDefault::Own) + } + + fn visit_i64(self, value: i64) -> Result { + T::deserialize(value.into_deserializer()).map(OwnOrDefault::Own) + } + + fn visit_u64(self, value: u64) -> Result { + T::deserialize(value.into_deserializer()).map(OwnOrDefault::Own) + } + } + + deserializer.deserialize_any(Visitor(PhantomData)) + } +} + #[derive(Debug, Default, Clone, Copy, Display, EnumIter, EnumString, Serialize, Deserialize, PartialEq, PartialOrd)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] @@ -50,11 +182,131 @@ pub enum VideoConstraint { #[strum(to_string = "2160p")] #[serde(rename = "2160p")] P2160, + #[strum(to_string = "4320p")] + #[serde(rename = "4320p")] + P4320, +} + +impl VideoConstraint { + /// The `VideoResolutionLevel` of hyper.video's settings that limits video to the constraint, `None` for no limit, + /// which the settings spell `null`. + pub const fn resolution_level(self) -> Option<&'static str> { + match self { + Self::None => None, + Self::P90 => Some("P90"), + Self::P144 => Some("P144"), + Self::P240 => Some("P240"), + Self::P360 => Some("P360"), + Self::P480 => Some("P480"), + Self::P720 => Some("P720"), + Self::P1080 => Some("P1080"), + Self::P1440 => Some("P1440"), + Self::P2160 => Some("P2160"), + Self::P4320 => Some("P4320"), + } + } + + /// The constraint whose [`Self::resolution_level`] is `level`, `None` for a level that no constraint has. + pub fn from_resolution_level(level: Option<&str>) -> Option { + Self::iter().find(|constraint| constraint.resolution_level() == level) + } +} + +/// The most video tracks that a stream encodes at once. +/// +/// The CLI and the config spell it `unlimited` or as a number of tracks. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum VideoMaxConcurrentTracks { + /// Every track of the stream, also what a participant's state holds before it reads the limit. + #[default] + Unlimited, + /// At most this many tracks. + Limit(usize), +} + +impl VideoMaxConcurrentTracks { + const UNLIMITED: &str = "unlimited"; + + /// The limit as hyper.video's `videoSendMaxConcurrentTracks` setting takes it, `None` for unlimited, which the + /// setting spells `null`. + pub const fn limit(self) -> Option { + match self { + Self::Unlimited => None, + Self::Limit(tracks) => Some(tracks), + } + } +} + +impl fmt::Display for VideoMaxConcurrentTracks { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unlimited => f.write_str(Self::UNLIMITED), + Self::Limit(tracks) => write!(f, "{tracks}"), + } + } +} + +impl FromStr for VideoMaxConcurrentTracks { + type Err = String; + + fn from_str(value: &str) -> Result { + if value == Self::UNLIMITED { + return Ok(Self::Unlimited); + } + value + .parse() + .map(Self::Limit) + .map_err(|_| format!("expected `{}` or a number of tracks, not `{value}`", Self::UNLIMITED)) + } +} + +impl Serialize for VideoMaxConcurrentTracks { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Unlimited => serializer.serialize_str(Self::UNLIMITED), + Self::Limit(tracks) => serializer.serialize_u64(*tracks as u64), + } + } +} + +impl<'de> Deserialize<'de> for VideoMaxConcurrentTracks { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + + impl de::Visitor<'_> for Visitor { + type Value = VideoMaxConcurrentTracks; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "`{}` or a number of tracks", VideoMaxConcurrentTracks::UNLIMITED) + } + + fn visit_u64(self, value: u64) -> Result { + usize::try_from(value) + .map(VideoMaxConcurrentTracks::Limit) + .map_err(|_| E::invalid_value(de::Unexpected::Unsigned(value), &self)) + } + + fn visit_i64(self, value: i64) -> Result { + usize::try_from(value) + .map(VideoMaxConcurrentTracks::Limit) + .map_err(|_| E::invalid_value(de::Unexpected::Signed(value), &self)) + } + + fn visit_str(self, value: &str) -> Result { + value.parse().map_err(E::custom) + } + } + + deserializer.deserialize_any(Visitor) + } } #[derive(Debug, Default, Clone, Copy, Display, EnumIter, EnumString, PartialEq, Eq)] pub enum VideoMaxConcurrentTracksPreset { + /// No limit of the participant's own, so hyper.video's default applies. #[default] + #[strum(to_string = "default")] + Default, #[strum(to_string = "unlimited")] Unlimited, #[strum(to_string = "1")] @@ -68,26 +320,29 @@ pub enum VideoMaxConcurrentTracksPreset { } impl VideoMaxConcurrentTracksPreset { - pub const fn to_option(self) -> Option { + /// The track limit that the preset picks. + pub const fn to_setting(self) -> OwnOrDefault { match self { - Self::Unlimited => None, - Self::One => Some(1), - Self::Two => Some(2), - Self::Three => Some(3), - Self::Four => Some(4), + Self::Default => OwnOrDefault::Default, + Self::Unlimited => OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited), + Self::One => OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(1)), + Self::Two => OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)), + Self::Three => OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(3)), + Self::Four => OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(4)), } } - pub const fn from_option(value: Option) -> Self { + pub const fn from_setting(value: OwnOrDefault) -> Self { match value { - None => Self::Unlimited, - Some(1) => Self::One, - Some(2) => Self::Two, - Some(3) => Self::Three, - Some(4) => Self::Four, + OwnOrDefault::Default => Self::Default, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited) => Self::Unlimited, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(1)) => Self::One, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)) => Self::Two, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(3)) => Self::Three, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(4)) => Self::Four, // Off-list values (e.g. set via config/CLI) have no preset; the TUI // selector falls back to Unlimited, while the table shows the real value. - Some(_) => Self::Unlimited, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(_)) => Self::Unlimited, } } } @@ -125,11 +380,14 @@ impl ParticipantBackendKind { mod tests { use super::{ NoiseSuppression, + OwnOrDefault, ParticipantBackendKind, VideoConstraint, + VideoMaxConcurrentTracks, VideoMaxConcurrentTracksPreset, }; use std::str::FromStr as _; + use strum::IntoEnumIterator as _; #[test] fn video_constraint_round_trips_hyper_media_setting_names() { @@ -144,6 +402,7 @@ mod tests { ("1080p", VideoConstraint::P1080), ("1440p", VideoConstraint::P1440), ("2160p", VideoConstraint::P2160), + ("4320p", VideoConstraint::P4320), ]; for (raw, expected) in values { @@ -154,6 +413,63 @@ mod tests { } } + #[test] + fn video_constraints_round_trip_through_resolution_levels() { + for constraint in VideoConstraint::iter() { + assert_eq!( + VideoConstraint::from_resolution_level(constraint.resolution_level()), + Some(constraint) + ); + } + assert_eq!( + VideoConstraint::from_resolution_level(Some("P4320")), + Some(VideoConstraint::P4320) + ); + assert_eq!(VideoConstraint::from_resolution_level(Some("P8640")), None); + } + + /// A setting reads and writes `default` for hyper.video's default, and an own value as the setting spells it. + #[test] + fn own_or_default_reads_default_or_own_value() { + assert_eq!( + OwnOrDefault::::from_str("default").unwrap(), + OwnOrDefault::Default + ); + assert_eq!(OwnOrDefault::::from_str("true").unwrap(), OwnOrDefault::Own(true)); + assert_eq!( + OwnOrDefault::::from_str("none").unwrap(), + OwnOrDefault::Own(VideoConstraint::None) + ); + assert!(OwnOrDefault::::from_str("maybe").is_err()); + + let values = [ + (serde_json::json!("default"), OwnOrDefault::Default), + ( + serde_json::json!(2), + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)), + ), + ( + serde_json::json!("unlimited"), + OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited), + ), + ]; + for (json, expected) in values { + assert_eq!(serde_json::to_value(expected).unwrap(), json); + assert_eq!( + serde_json::from_value::>(json).unwrap(), + expected + ); + } + assert_eq!( + serde_json::from_value::>(serde_json::json!(false)).unwrap(), + OwnOrDefault::Own(false) + ); + assert_eq!( + serde_json::from_value::>(serde_json::Value::Null).unwrap(), + OwnOrDefault::Default + ); + } + #[test] fn noise_suppression_round_trips_hyper_media_setting_names() { let values = [ @@ -173,28 +489,59 @@ mod tests { } #[test] - fn video_track_limit_presets_convert_to_nullable_track_counts() { - assert_eq!(VideoMaxConcurrentTracksPreset::Unlimited.to_option(), None); - assert_eq!(VideoMaxConcurrentTracksPreset::One.to_option(), Some(1)); - assert_eq!(VideoMaxConcurrentTracksPreset::Two.to_option(), Some(2)); - assert_eq!(VideoMaxConcurrentTracksPreset::Three.to_option(), Some(3)); - assert_eq!(VideoMaxConcurrentTracksPreset::Four.to_option(), Some(4)); - assert_eq!( - VideoMaxConcurrentTracksPreset::from_option(None), - VideoMaxConcurrentTracksPreset::Unlimited - ); - assert_eq!( - VideoMaxConcurrentTracksPreset::from_option(Some(1)), - VideoMaxConcurrentTracksPreset::One - ); - assert_eq!( - VideoMaxConcurrentTracksPreset::from_option(Some(4)), - VideoMaxConcurrentTracksPreset::Four - ); + fn video_max_concurrent_tracks_reads_unlimited_or_track_count() { + let values = [ + ( + "unlimited", + serde_json::json!("unlimited"), + VideoMaxConcurrentTracks::Unlimited, + ), + ("2", serde_json::json!(2), VideoMaxConcurrentTracks::Limit(2)), + ]; + + for (raw, json, expected) in values { + assert_eq!(VideoMaxConcurrentTracks::from_str(raw).unwrap(), expected); + assert_eq!(expected.to_string(), raw); + assert_eq!(serde_json::to_value(expected).unwrap(), json); + assert_eq!( + serde_json::from_value::(json).unwrap(), + expected + ); + assert_eq!( + serde_json::from_value::(raw.into()).unwrap(), + expected + ); + } + assert!(VideoMaxConcurrentTracks::from_str("many").is_err()); + assert!(serde_json::from_value::(serde_json::json!(-1)).is_err()); + } + + #[test] + fn video_track_limit_presets_convert_to_track_limits() { + let limits = [ + (VideoMaxConcurrentTracksPreset::Default, OwnOrDefault::Default), + ( + VideoMaxConcurrentTracksPreset::Unlimited, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited), + ), + ( + VideoMaxConcurrentTracksPreset::One, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(1)), + ), + ( + VideoMaxConcurrentTracksPreset::Four, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(4)), + ), + ]; + + for (preset, limit) in limits { + assert_eq!(preset.to_setting(), limit); + assert_eq!(VideoMaxConcurrentTracksPreset::from_setting(limit), preset); + } // Off-list live values fall back to Unlimited for TUI preselection only; // the participants table displays the real number separately. assert_eq!( - VideoMaxConcurrentTracksPreset::from_option(Some(8)), + VideoMaxConcurrentTracksPreset::from_setting(OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(8))), VideoMaxConcurrentTracksPreset::Unlimited ); } diff --git a/config/src/default-config.yaml b/config/src/default-config.yaml index 2a773c0..f99600f 100644 --- a/config/src/default-config.yaml +++ b/config/src/default-config.yaml @@ -44,10 +44,10 @@ device_farm: debug: false audio_enabled: true video_enabled: true -auto_gain_control: true +auto_gain_control: default noise_suppression: none transport: webtransport -video_constraint_publish_webcam: none -video_constraint_subscribe: none -video_max_concurrent_tracks: -blur: false +video_constraint_publish_webcam: default +video_constraint_subscribe: default +video_max_concurrent_tracks: default +blur: default diff --git a/config/src/lib.rs b/config/src/lib.rs index 256277e..2c9ca45 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -25,11 +25,13 @@ pub use browser_config::BrowserConfig; pub use client_config::{ NoiseSuppression, NoiseSuppressionIter, + OwnOrDefault, ParticipantBackendKind, TransportMode, TransportModeIter, VideoConstraint, VideoConstraintIter, + VideoMaxConcurrentTracks, VideoMaxConcurrentTracksPreset, VideoMaxConcurrentTracksPresetIter, }; @@ -92,28 +94,24 @@ pub struct Config { pub video_enabled: bool, #[serde(default)] pub screenshare_enabled: bool, - #[serde(default = "default_auto_gain_control")] - pub auto_gain_control: bool, + #[serde(default)] + pub auto_gain_control: OwnOrDefault, #[serde(default)] pub noise_suppression: NoiseSuppression, #[serde(default)] pub transport: TransportMode, #[serde(default)] - pub video_constraint_publish_webcam: VideoConstraint, + pub video_constraint_publish_webcam: OwnOrDefault, #[serde(default)] - pub video_constraint_subscribe: VideoConstraint, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub video_max_concurrent_tracks: Option, + pub video_constraint_subscribe: OwnOrDefault, + #[serde(default)] + pub video_max_concurrent_tracks: OwnOrDefault, #[serde(default)] - pub blur: bool, + pub blur: OwnOrDefault, } const DEFAULT_CONFIG: &str = include_str!("default-config.yaml"); -const fn default_auto_gain_control() -> bool { - true -} - impl Default for Config { fn default() -> Self { let mut config: Self = yaml_serde::from_str(DEFAULT_CONFIG).expect("Failed to parse default config"); @@ -193,7 +191,10 @@ impl config::Source for Config { cache.insert("audio_enabled".to_string(), self.audio_enabled.into()); cache.insert("video_enabled".to_string(), self.video_enabled.into()); cache.insert("screenshare_enabled".to_string(), self.screenshare_enabled.into()); - cache.insert("auto_gain_control".to_string(), self.auto_gain_control.into()); + cache.insert( + "auto_gain_control".to_string(), + self.auto_gain_control.to_string().into(), + ); cache.insert( "noise_suppression".to_string(), self.noise_suppression.to_string().into(), @@ -207,10 +208,11 @@ impl config::Source for Config { "video_constraint_subscribe".to_string(), self.video_constraint_subscribe.to_string().into(), ); - if let Some(value) = self.video_max_concurrent_tracks { - cache.insert("video_max_concurrent_tracks".to_string(), (value as i64).into()); - } - cache.insert("blur".to_string(), self.blur.into()); + cache.insert( + "video_max_concurrent_tracks".to_string(), + self.video_max_concurrent_tracks.to_string().into(), + ); + cache.insert("blur".to_string(), self.blur.to_string().into()); if let Some(value) = self.fake_media_selected { cache.insert("fake_media_selected".to_string(), (value as u64).into()); } @@ -517,18 +519,82 @@ video_max_concurrent_tracks: 2 .try_deserialize() .expect("failed to deserialize config"); - assert_eq!(config.video_constraint_publish_webcam, VideoConstraint::P480); - assert_eq!(config.video_constraint_subscribe, VideoConstraint::P720); - assert_eq!(config.video_max_concurrent_tracks, Some(2)); + assert_eq!( + config.video_constraint_publish_webcam, + OwnOrDefault::Own(VideoConstraint::P480) + ); + assert_eq!( + config.video_constraint_subscribe, + OwnOrDefault::Own(VideoConstraint::P720) + ); + assert_eq!( + config.video_max_concurrent_tracks, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)) + ); + } + + /// The settings that a participant can leave to hyper.video load an explicit value as its own and `default` as + /// none, from a config file, from a config passed on as a source, and from a saved config. + #[test] + fn own_or_default_settings_load_from_file_source_and_saved_config() { + let from_file: Config = config::Config::builder() + .add_source(Config::default()) + .add_source(config::File::from_str( + r#" +auto_gain_control: true +video_constraint_publish_webcam: none +video_constraint_subscribe: default +video_max_concurrent_tracks: unlimited +blur: false +"#, + config::FileFormat::Yaml, + )) + .build() + .expect("failed to build config") + .try_deserialize() + .expect("failed to deserialize config"); + let from_source: Config = config::Config::builder() + .add_source(from_file.clone()) + .build() + .expect("failed to build config") + .try_deserialize() + .expect("failed to deserialize config"); + let saved = yaml_serde::to_string(&from_file).expect("failed to serialize config"); + let from_saved: Config = config::Config::builder() + .add_source(Config::default()) + .add_source(config::File::from_str(&saved, config::FileFormat::Yaml)) + .build() + .expect("failed to build config") + .try_deserialize() + .expect("failed to deserialize config"); + + for config in [from_file, from_source, from_saved] { + assert_eq!(config.auto_gain_control, OwnOrDefault::Own(true)); + assert_eq!( + config.video_constraint_publish_webcam, + OwnOrDefault::Own(VideoConstraint::None) + ); + assert_eq!(config.video_constraint_subscribe, OwnOrDefault::Default); + assert_eq!( + config.video_max_concurrent_tracks, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited) + ); + assert_eq!(config.blur, OwnOrDefault::Own(false)); + } } + /// The default config leaves every setting that a participant can leave to hyper.video to it, and runs no noise + /// suppression model. #[test] - fn default_video_max_concurrent_tracks_is_unlimited() { + fn default_config_follows_hyper_video_but_suppresses_no_noise() { let config = Config::default(); - assert_eq!(config.video_constraint_publish_webcam, VideoConstraint::None); - assert_eq!(config.video_constraint_subscribe, VideoConstraint::None); - assert_eq!(config.video_max_concurrent_tracks, None); + assert_eq!(config.auto_gain_control, OwnOrDefault::Default); + assert_eq!(config.video_constraint_publish_webcam, OwnOrDefault::Default); + assert_eq!(config.video_constraint_subscribe, OwnOrDefault::Default); + assert_eq!(config.video_max_concurrent_tracks, OwnOrDefault::Default); + assert_eq!(config.blur, OwnOrDefault::Default); + assert_eq!(config.noise_suppression, NoiseSuppression::Disabled); } #[test] diff --git a/src/headless.rs b/src/headless.rs index 426dbf6..72407bc 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -6,10 +6,12 @@ use client_simulator_browser::participant::{ use client_simulator_config::{ Config, NoiseSuppression, + OwnOrDefault, ParticipantBackendKind, TransportMode, TuiArgs, VideoConstraint, + VideoMaxConcurrentTracks, }; use eyre::{ Context as _, @@ -49,8 +51,9 @@ pub struct HeadlessArgs { #[clap(long = "screenshare-enabled", value_parser = clap::builder::BoolishValueParser::new())] pub screenshare_enabled: Option, - #[clap(long = "auto-gain-control", value_parser = clap::builder::BoolishValueParser::new())] - pub auto_gain_control: Option, + /// Volume normalization: true, false, or default to leave it to hyper.video. + #[clap(long = "auto-gain-control", value_name = "ON")] + pub auto_gain_control: Option>, /// Noise suppression model: none or hyper-voice. Cloudflare participants always run with none. #[clap(long = "noise-suppression", value_name = "MODEL")] @@ -59,17 +62,21 @@ pub struct HeadlessArgs { #[clap(long, value_name = "MODE")] pub transport: Option, + /// Outgoing webcam resolution limit, such as 720p, none for no limit, or default to leave it to hyper.video. #[clap(long = "video-constraint-publish-webcam", value_name = "CONSTRAINT")] - pub video_constraint_publish_webcam: Option, + pub video_constraint_publish_webcam: Option>, + /// Incoming video resolution limit, with the values of --video-constraint-publish-webcam. #[clap(long = "video-constraint-subscribe", value_name = "CONSTRAINT")] - pub video_constraint_subscribe: Option, + pub video_constraint_subscribe: Option>, + /// Video track limit: a number of tracks, unlimited, or default to leave it to hyper.video. #[clap(long = "video-max-concurrent-tracks", value_name = "TRACKS")] - pub video_max_concurrent_tracks: Option, + pub video_max_concurrent_tracks: Option>, - #[clap(long, value_parser = clap::builder::BoolishValueParser::new())] - pub blur: Option, + /// Background blur: true, false, or default to leave it to hyper.video. + #[clap(long, value_name = "ON")] + pub blur: Option>, #[clap(long = "participant", value_name = "JSON")] pub participants: Vec, @@ -85,13 +92,13 @@ struct ParticipantOverride { audio_enabled: Option, video_enabled: Option, screenshare_enabled: Option, - auto_gain_control: Option, + auto_gain_control: Option>, noise_suppression: Option, transport: Option, - video_constraint_publish_webcam: Option, - video_constraint_subscribe: Option, - video_max_concurrent_tracks: Option, - blur: Option, + video_constraint_publish_webcam: Option>, + video_constraint_subscribe: Option>, + video_max_concurrent_tracks: Option>, + blur: Option>, } pub async fn run(args: HeadlessArgs, filter: EnvFilter) -> Result { @@ -163,7 +170,7 @@ fn apply_cli_overrides(config: &mut Config, args: &HeadlessArgs) { config.video_constraint_subscribe = value; } if let Some(value) = args.video_max_concurrent_tracks { - config.video_max_concurrent_tracks = Some(value); + config.video_max_concurrent_tracks = value; } if let Some(blur) = args.blur { config.blur = blur; @@ -208,7 +215,7 @@ fn apply_participant_override(mut config: Config, override_: ParticipantOverride config.video_constraint_subscribe = value; } if let Some(value) = override_.video_max_concurrent_tracks { - config.video_max_concurrent_tracks = Some(value); + config.video_max_concurrent_tracks = value; } if let Some(blur) = override_.blur { config.blur = blur; @@ -435,12 +442,54 @@ mod tests { ]); let args = cli.args; - assert_eq!(args.video_constraint_publish_webcam, Some(VideoConstraint::P480)); - assert_eq!(args.video_constraint_subscribe, Some(VideoConstraint::P720)); - assert_eq!(args.video_max_concurrent_tracks, Some(2)); + assert_eq!( + args.video_constraint_publish_webcam, + Some(OwnOrDefault::Own(VideoConstraint::P480)) + ); + assert_eq!( + args.video_constraint_subscribe, + Some(OwnOrDefault::Own(VideoConstraint::P720)) + ); + assert_eq!( + args.video_max_concurrent_tracks, + Some(OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2))) + ); assert_eq!(args.participants.len(), 1); } + #[test] + fn cli_parsing_accepts_unlimited_video_max_concurrent_tracks() { + let cli = TestHeadlessCli::parse_from(["headless", "--video-max-concurrent-tracks", "unlimited"]); + + assert_eq!( + cli.args.video_max_concurrent_tracks, + Some(OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited)) + ); + } + + /// The CLI and `--participant` JSON take `default` to leave a setting to hyper.video, next to explicit values. + #[test] + fn cli_and_participant_json_take_default() { + let cli = TestHeadlessCli::parse_from(["headless", "--blur", "default", "--auto-gain-control", "false"]); + + assert_eq!(cli.args.blur, Some(OwnOrDefault::Default)); + assert_eq!(cli.args.auto_gain_control, Some(OwnOrDefault::Own(false))); + + let global_config = Config { + video_constraint_subscribe: OwnOrDefault::Own(VideoConstraint::P720), + ..Default::default() + }; + + let configs = build_participant_configs( + global_config, + &[r#"{"video_constraint_subscribe":"default","blur":true}"#.to_string()], + ) + .expect("participant configs"); + + assert_eq!(configs[0].video_constraint_subscribe, OwnOrDefault::Default); + assert_eq!(configs[0].blur, OwnOrDefault::Own(true)); + } + #[test] fn participant_json_overrides_global_config() { let global_config = Config { @@ -484,9 +533,9 @@ mod tests { #[test] fn participant_json_overrides_video_constraints_and_treats_null_tracks_as_absent() { let global_config = Config { - video_constraint_publish_webcam: VideoConstraint::P720, - video_constraint_subscribe: VideoConstraint::P720, - video_max_concurrent_tracks: Some(2), + video_constraint_publish_webcam: OwnOrDefault::Own(VideoConstraint::P720), + video_constraint_subscribe: OwnOrDefault::Own(VideoConstraint::P720), + video_max_concurrent_tracks: OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)), ..Default::default() }; @@ -497,23 +546,45 @@ mod tests { .expect("participant configs"); // publish overridden; subscribe omitted -> inherits global; - // max tracks null is treated as absent -> inherits global Some(2). - assert_eq!(configs[0].video_constraint_publish_webcam, VideoConstraint::P360); - assert_eq!(configs[0].video_constraint_subscribe, VideoConstraint::P720); - assert_eq!(configs[0].video_max_concurrent_tracks, Some(2)); + // max tracks null is treated as absent -> inherits global 2. + assert_eq!( + configs[0].video_constraint_publish_webcam, + OwnOrDefault::Own(VideoConstraint::P360) + ); + assert_eq!( + configs[0].video_constraint_subscribe, + OwnOrDefault::Own(VideoConstraint::P720) + ); + assert_eq!( + configs[0].video_max_concurrent_tracks, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)) + ); } #[test] - fn participant_json_numeric_tracks_override_global() { + fn participant_json_tracks_override_global() { let global_config = Config { - video_max_concurrent_tracks: None, + video_max_concurrent_tracks: OwnOrDefault::Default, ..Default::default() }; - let configs = build_participant_configs(global_config, &[r#"{"video_max_concurrent_tracks":3}"#.to_string()]) - .expect("participant configs"); + let configs = build_participant_configs( + global_config, + &[ + r#"{"video_max_concurrent_tracks":3}"#.to_string(), + r#"{"video_max_concurrent_tracks":"unlimited"}"#.to_string(), + ], + ) + .expect("participant configs"); - assert_eq!(configs[0].video_max_concurrent_tracks, Some(3)); + assert_eq!( + configs[0].video_max_concurrent_tracks, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(3)) + ); + assert_eq!( + configs[1].video_max_concurrent_tracks, + OwnOrDefault::Own(VideoMaxConcurrentTracks::Unlimited) + ); } #[test] diff --git a/tui/examples/join-hyper-session.rs b/tui/examples/join-hyper-session.rs index b600024..88d3ace 100644 --- a/tui/examples/join-hyper-session.rs +++ b/tui/examples/join-hyper-session.rs @@ -5,6 +5,7 @@ use client_simulator_browser::{ }; use client_simulator_config::{ Config, + OwnOrDefault, ParticipantConfig, }; use eyre::Result; @@ -31,7 +32,7 @@ async fn run(Args { url }: Args) -> Result<()> { session_url: url.clone(), app_config: Config { headless: false, - auto_gain_control: true, + auto_gain_control: OwnOrDefault::Own(true), ..Default::default() }, }, diff --git a/tui/src/tui/components/browser_start.rs b/tui/src/tui/components/browser_start.rs index 12bbbaa..734b8cb 100644 --- a/tui/src/tui/components/browser_start.rs +++ b/tui/src/tui/components/browser_start.rs @@ -17,6 +17,7 @@ use client_simulator_browser::participant::ParticipantStore; use client_simulator_config::{ Config, NoiseSuppression, + OwnOrDefault, ParticipantBackendKind, TransportMode, VideoConstraint, @@ -67,7 +68,9 @@ impl SelectedField { SelectedField::Mute => " Mute audio? to toggle. ", SelectedField::VideoDisable => " Enable video? to toggle. ", SelectedField::ScreenshareDisable => " Enable screenshare? to toggle. ", - SelectedField::AutoGainControl => " Automatically adjust volume? to toggle. ", + SelectedField::AutoGainControl => { + " Automatically adjust volume? to cycle through hyper.video's default, on and off. " + } SelectedField::NoiseSuppression => " Enable noise suppression? to select noise suppression model. ", SelectedField::Transport => " Select transport protocol. to select. ", SelectedField::VideoConstraintPublishWebcam => { @@ -77,7 +80,9 @@ impl SelectedField { SelectedField::VideoMaxConcurrentTracks => { " Select max concurrent webcam tracks. to select. " } - SelectedField::BackgroundBlur => " Enable background blur? to toggle. ", + SelectedField::BackgroundBlur => { + " Enable background blur? to cycle through hyper.video's default, on and off. " + } SelectedField::Headless => " Run the browser in headless mode? When disabled, will show a browser window with which you can interact. to toggle. ", SelectedField::Backend => " Select the participant backend. to select, to reset. ", SelectedField::StartBrowser => " Start a new browser session and join a hyper.video session. to start. ", @@ -120,13 +125,27 @@ pub struct BrowserStart { fake_media_builtin_list: Option>, noise_suppression_list: Option>, transport_list: Option>, - video_constraint_publish_webcam_list: Option>, - video_constraint_subscribe_list: Option>, + video_constraint_publish_webcam_list: Option>>, + video_constraint_subscribe_list: Option>>, video_max_concurrent_tracks_list: Option>, backend_list: Option>, participant_store: ParticipantStore, } +/// The values of a video constraint that the start screen lists: hyper.video's default, then each constraint. +fn video_constraint_settings() -> impl Iterator> { + std::iter::once(OwnOrDefault::Default).chain(VideoConstraint::iter().map(OwnOrDefault::Own)) +} + +/// The value after `setting` in the order that Enter cycles through: hyper.video's default, on, off. +const fn cycle(setting: OwnOrDefault) -> OwnOrDefault { + match setting { + OwnOrDefault::Default => OwnOrDefault::Own(true), + OwnOrDefault::Own(true) => OwnOrDefault::Own(false), + OwnOrDefault::Own(false) => OwnOrDefault::Default, + } +} + fn participant_backend_options() -> impl Iterator { ParticipantBackendKind::iter() .filter(|backend| cfg!(debug_assertions) || *backend != ParticipantBackendKind::RemoteStub) @@ -349,7 +368,7 @@ impl Component for BrowserStart { KeyCode::Enter => { match list.finish() { Ok(value) => { - self.config.video_max_concurrent_tracks = value.to_option(); + self.config.video_max_concurrent_tracks = value.to_setting(); } Err(err) => { error!(?err, "Failed to parse"); @@ -643,7 +662,7 @@ impl Component for BrowserStart { BrowserStartAction::StartSelectVideoConstraintPublishWebcam => { self.video_constraint_publish_webcam_list = Some(EnumListInput::new( "Outgoing webcam video constraint", - VideoConstraint::iter(), + video_constraint_settings(), self.config.video_constraint_publish_webcam, )); return Ok(None); @@ -652,7 +671,7 @@ impl Component for BrowserStart { BrowserStartAction::StartSelectVideoConstraintSubscribe => { self.video_constraint_subscribe_list = Some(EnumListInput::new( "Incoming video constraint", - VideoConstraint::iter(), + video_constraint_settings(), self.config.video_constraint_subscribe, )); return Ok(None); @@ -662,7 +681,7 @@ impl Component for BrowserStart { self.video_max_concurrent_tracks_list = Some(EnumListInput::new( "Max concurrent webcam tracks", VideoMaxConcurrentTracksPreset::iter(), - VideoMaxConcurrentTracksPreset::from_option(self.config.video_max_concurrent_tracks), + VideoMaxConcurrentTracksPreset::from_setting(self.config.video_max_concurrent_tracks), )); return Ok(None); } @@ -700,10 +719,10 @@ impl Component for BrowserStart { self.config.screenshare_enabled = !self.config.screenshare_enabled; } SelectedField::AutoGainControl => { - self.config.auto_gain_control = !self.config.auto_gain_control; + self.config.auto_gain_control = cycle(self.config.auto_gain_control); } SelectedField::BackgroundBlur => { - self.config.blur = !self.config.blur; + self.config.blur = cycle(self.config.blur); } SelectedField::Headless => { self.config.headless = !self.config.headless; @@ -858,7 +877,7 @@ impl Component for BrowserStart { current_row_index += 1; // --- Auto gain control --- - let widget = widgets::label_and_bool( + let widget = widgets::label_and_bool_setting( form_labels[current_row_index], self.config.auto_gain_control, max_length, @@ -916,11 +935,7 @@ impl Component for BrowserStart { current_row_index += 1; // --- Max concurrent video tracks --- - let max_tracks = self - .config - .video_max_concurrent_tracks - .map(|value| value.to_string()) - .unwrap_or_else(|| "unlimited".to_string()); + let max_tracks = self.config.video_max_concurrent_tracks.to_string(); let widget = widgets::label_and_text( form_labels[current_row_index], &max_tracks, @@ -932,7 +947,7 @@ impl Component for BrowserStart { current_row_index += 1; // --- Background blur --- - let widget = widgets::label_and_bool( + let widget = widgets::label_and_bool_setting( form_labels[current_row_index], self.config.blur, max_length, diff --git a/tui/src/tui/components/participants.rs b/tui/src/tui/components/participants.rs index 2058004..8422682 100644 --- a/tui/src/tui/components/participants.rs +++ b/tui/src/tui/components/participants.rs @@ -16,6 +16,7 @@ use client_simulator_browser::participant::ParticipantStore; use client_simulator_config::{ Config, NoiseSuppression, + OwnOrDefault, VideoConstraint, VideoMaxConcurrentTracksPreset, }; @@ -274,10 +275,15 @@ impl Component for Participants { )); } VideoSetting::MaxTracks => { + // A running participant's list offers explicit limits only, and preselects the + // limit that applies. self.video_max_concurrent_tracks_list = Some(EnumListInput::new( "Max concurrent webcam tracks", - VideoMaxConcurrentTracksPreset::iter(), - VideoMaxConcurrentTracksPreset::from_option(state.video_max_concurrent_tracks), + VideoMaxConcurrentTracksPreset::iter() + .filter(|preset| *preset != VideoMaxConcurrentTracksPreset::Default), + VideoMaxConcurrentTracksPreset::from_setting(OwnOrDefault::Own( + state.video_max_concurrent_tracks, + )), )); } } @@ -344,9 +350,9 @@ impl Component for Participants { if let Some(mut list) = self.video_max_concurrent_tracks_list.take() { match key.code { KeyCode::Enter => { - if let Ok(value) = list.finish() { + if let Some(value) = list.finish().ok().and_then(|preset| preset.to_setting().own()) { if let Some(participant) = self.selected.as_ref().and_then(|s| self.participants.get(s)) { - participant.set_video_max_concurrent_tracks(value.to_option()); + participant.set_video_max_concurrent_tracks(value); } } return Ok(Some(Action::Activate(ActivateAction::Participants))); @@ -519,6 +525,7 @@ impl Component for Participants { let subscribe = state.video_constraint_subscribe.to_string(); let tracks = state .video_max_concurrent_tracks + .limit() .map(|value| value.to_string()) .unwrap_or_else(|| "∞".to_string()); let video_constraints = format!("out:{publish} in:{subscribe} t:{tracks}"); diff --git a/tui/src/tui/widgets/label_and_text.rs b/tui/src/tui/widgets/label_and_text.rs index 2711daa..6450aa0 100644 --- a/tui/src/tui/widgets/label_and_text.rs +++ b/tui/src/tui/widgets/label_and_text.rs @@ -1,4 +1,5 @@ use crate::tui::theme::Theme; +use client_simulator_config::OwnOrDefault; use ratatui::{ prelude::*, widgets::*, @@ -40,3 +41,18 @@ pub(crate) fn label_and_bool<'a>( let content = if content { "[x]" } else { "[ ]" }; label_and_text(label, content, label_width, selected, theme) } + +/// A label and a switch that the participant either sets, as [`label_and_bool`] shows it, or leaves to hyper.video, +/// shown as `default`. +pub(crate) fn label_and_bool_setting<'a>( + label: impl ToString, + content: OwnOrDefault, + label_width: usize, + selected: bool, + theme: &Theme, +) -> Paragraph<'a> { + match content { + OwnOrDefault::Default => label_and_text(label, content, label_width, selected, theme), + OwnOrDefault::Own(content) => label_and_bool(label, content, label_width, selected, theme), + } +} diff --git a/tui/src/tui/widgets/mod.rs b/tui/src/tui/widgets/mod.rs index d026401..6e16828 100644 --- a/tui/src/tui/widgets/mod.rs +++ b/tui/src/tui/widgets/mod.rs @@ -8,6 +8,7 @@ pub(crate) use button::button; pub(crate) use enum_list_input::EnumListInput; pub(crate) use label_and_text::{ label_and_bool, + label_and_bool_setting, label_and_text, }; pub(crate) use list_input::ListInput;