From 9e9f96d9a58279830133a6f7ca871cc20f82f813 Mon Sep 17 00:00:00 2001 From: Julian Ostarek Date: Thu, 22 Oct 2026 05:54:14 +0200 Subject: [PATCH] Edit Hyper Core settings through hyper.settings.client hyper.video #6017 and #6020 moved noise suppression, volume normalization, background blur, the video resolution limits and the video track limit into the core's settings layers, and removed the setters on hyper.settings.media that the settings commands called. The commands now read the values that apply from hyper.settings.client.report and edit the participant layer with hyper.settings.client.edit. Auto gain control is volume normalization, which the web app's "Automatically adjust volume" switch turns on and off. Video constraints convert to resolution levels, and no limit to null. They take 4320p too, hyper.video's highest resolution level. Auto gain control, background blur, both video constraints and the track limit take "default" next to their values, and default to it. "default" unsets the setting in the participant layer, so that an admin's value for the session applies, else hyper.video's default. Noise suppression keeps setting its value, "none" by default. Each launch setting applies on its own, so that a failing one, such as any settings edit on a deployment without hyper.settings.client, no longer skips the transport and the microphone and camera state. --- .codex/skills/run-headless-simulator/SKILL.md | 10 +- browser/src/participant/cloudflare/mod.rs | 68 ++- browser/src/participant/frontend/commands.rs | 271 ++++++++---- browser/src/participant/frontend/core.rs | 89 ++-- browser/src/participant/frontend/lite.rs | 15 +- browser/src/participant/mod.rs | 2 +- browser/src/participant/remote_stub.rs | 33 +- browser/src/participant/shared/messages.rs | 3 +- browser/src/participant/shared/spec.rs | 40 +- browser/src/participant/shared/state.rs | 3 +- config/src/client_config.rs | 411 ++++++++++++++++-- config/src/default-config.yaml | 10 +- config/src/lib.rs | 112 ++++- src/headless.rs | 129 ++++-- tui/examples/join-hyper-session.rs | 3 +- tui/src/tui/components/browser_start.rs | 49 ++- tui/src/tui/components/participants.rs | 15 +- tui/src/tui/widgets/label_and_text.rs | 16 + tui/src/tui/widgets/mod.rs | 1 + 19 files changed, 1007 insertions(+), 273 deletions(-) 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;