diff --git a/.changeset/fix_for_dynacast_error.md b/.changeset/fix_for_dynacast_error.md new file mode 100644 index 000000000..2a1c437ca --- /dev/null +++ b/.changeset/fix_for_dynacast_error.md @@ -0,0 +1,6 @@ +--- +livekit: patch +livekit-ffi: patch +--- + +Fix for dynacast error - #1213 (@MaxHeimbrock) diff --git a/livekit/src/room/participant/local_participant.rs b/livekit/src/room/participant/local_participant.rs index c3d7f00c6..b02e01e97 100644 --- a/livekit/src/room/participant/local_participant.rs +++ b/livekit/src/room/participant/local_participant.rs @@ -356,6 +356,26 @@ impl LocalParticipant { &self, track: LocalTrack, options: TrackPublishOptions, + ) -> RoomResult { + self.publish_track_with_video_send_encodings(track, options, None).await + } + + /// Publishes a track without providing video send encodings to WebRTC. + #[doc(hidden)] + #[cfg(feature = "__lk-e2e-test")] + pub async fn publish_track_without_video_send_encodings( + &self, + track: LocalTrack, + options: TrackPublishOptions, + ) -> RoomResult { + self.publish_track_with_video_send_encodings(track, options, Some(Vec::new())).await + } + + async fn publish_track_with_video_send_encodings( + &self, + track: LocalTrack, + options: TrackPublishOptions, + video_send_encodings: Option>, ) -> RoomResult { let disable_red = self.local.encryption_type != EncryptionType::None || !options.red; @@ -388,7 +408,8 @@ impl LocalParticipant { req.width = resolution.width; req.height = resolution.height; - encodings = compute_video_encodings(req.width, req.height, &options); + encodings = video_send_encodings + .unwrap_or_else(|| compute_video_encodings(req.width, req.height, &options)); req.layers = video_layers_from_encodings(req.width, req.height, &encodings); // Populate simulcast_codecs so the server knows this track has diff --git a/livekit/src/room/track/local_video_track.rs b/livekit/src/room/track/local_video_track.rs index 90c174f42..20084fd9f 100644 --- a/livekit/src/room/track/local_video_track.rs +++ b/livekit/src/room/track/local_video_track.rs @@ -370,17 +370,19 @@ impl LocalVideoTrack { let mut changed = false; for encoding in &mut params.encodings { - let quality = crate::options::video_quality_for_rid_or_default(&encoding.rid); + // The SFU addresses layers by spatial index (0 = Low), so a + // single rid-less encoding is addressed as Low, not High. + let rid = if encoding.rid.is_empty() { "q" } else { encoding.rid.as_str() }; + let quality = crate::options::video_quality_for_rid_or_default(rid); - let should_active = qualities - .iter() - .find(|q| q.quality == quality as i32) - .map(|q| q.enabled) - .unwrap_or(false); + // A quality missing from the update is left untouched. + let Some(subscribed) = qualities.iter().find(|q| q.quality == quality as i32) else { + continue; + }; - if encoding.active != should_active { + if encoding.active != subscribed.enabled { changed = true; - encoding.active = should_active; + encoding.active = subscribed.enabled; } } @@ -394,11 +396,10 @@ impl LocalVideoTrack { }) .collect(); - sender - .set_parameters(params) - .map_err(|e| RoomError::Internal(format!("failed to set sender parameters: {}", e)))?; - if changed { + sender.set_parameters(params).map_err(|e| { + RoomError::Internal(format!("failed to set sender parameters: {}", e)) + })?; log::debug!("dynacast: layers changed -> [{}]", layers.join(", ")); } else { log::debug!("dynacast: layers unchanged [{}]", layers.join(", ")); @@ -406,4 +407,21 @@ impl LocalVideoTrack { Ok(()) } + + /// Applies publishing-layer quality state for end-to-end tests. + #[doc(hidden)] + #[cfg(feature = "__lk-e2e-test")] + pub fn set_publishing_layers_for_test( + &self, + qualities: &[(PublishingLayerQuality, bool)], + ) -> RoomResult<()> { + let qualities: Vec = qualities + .iter() + .map(|(quality, enabled)| proto::SubscribedQuality { + quality: proto::VideoQuality::from(*quality) as i32, + enabled: *enabled, + }) + .collect(); + self.set_publishing_layers(&qualities) + } } diff --git a/livekit/tests/common/e2e/video.rs b/livekit/tests/common/e2e/video.rs index fac27cac1..ff175f7f9 100644 --- a/livekit/tests/common/e2e/video.rs +++ b/livekit/tests/common/e2e/video.rs @@ -63,6 +63,46 @@ impl SolidColorTrack { } pub async fn publish(&mut self, codec: VideoCodec, simulcast: bool) -> RoomResult<()> { + self.publish_with_options(TrackPublishOptions { + video_codec: codec, + simulcast, + ..Default::default() + }) + .await + } + + #[cfg(feature = "__lk-e2e-test")] + pub async fn publish_without_video_send_encodings( + &mut self, + codec: VideoCodec, + ) -> RoomResult<()> { + let options = + TrackPublishOptions { video_codec: codec, simulcast: false, ..Default::default() }; + self.publish_with_options_without_video_send_encodings(options).await + } + + async fn publish_with_options(&mut self, options: TrackPublishOptions) -> RoomResult<()> { + let (close_tx, close_rx) = oneshot::channel(); + let track = LocalVideoTrack::create_video_track( + "solid-color-track", + RtcVideoSource::Native(self.rtc_source.clone()), + ); + let task = + tokio::spawn(Self::track_task(close_rx, self.rtc_source.clone(), self.params.clone())); + self.room + .local_participant() + .publish_track(LocalTrack::Video(track.clone()), options) + .await?; + let handle = TrackHandle { close_tx, track, task }; + self.handle = Some(handle); + Ok(()) + } + + #[cfg(feature = "__lk-e2e-test")] + async fn publish_with_options_without_video_send_encodings( + &mut self, + options: TrackPublishOptions, + ) -> RoomResult<()> { let (close_tx, close_rx) = oneshot::channel(); let track = LocalVideoTrack::create_video_track( "solid-color-track", @@ -72,10 +112,7 @@ impl SolidColorTrack { tokio::spawn(Self::track_task(close_rx, self.rtc_source.clone(), self.params.clone())); self.room .local_participant() - .publish_track( - LocalTrack::Video(track.clone()), - TrackPublishOptions { video_codec: codec, simulcast, ..Default::default() }, - ) + .publish_track_without_video_send_encodings(LocalTrack::Video(track.clone()), options) .await?; let handle = TrackHandle { close_tx, track, task }; self.handle = Some(handle); diff --git a/livekit/tests/dynacast_test.rs b/livekit/tests/dynacast_test.rs index 8e58cfafd..094477ff1 100644 --- a/livekit/tests/dynacast_test.rs +++ b/livekit/tests/dynacast_test.rs @@ -332,6 +332,117 @@ async fn test_dynacast() -> Result<()> { Ok(()) } +/// Verifies dynacast with a publisher that does not provide RTP send encodings. +/// +/// WebRTC creates a single default encoding without a RID in this case. The SFU +/// addresses that layer as low quality, so the publisher should still be able +/// to pause and resume it from low-quality `SubscribedQualityUpdate` messages. +#[cfg(feature = "__lk-e2e-test")] +#[test_log::test(tokio::test)] +async fn test_dynacast_without_video_send_encodings() -> Result<()> { + let mut pub_room_opts = RoomOptions::default(); + pub_room_opts.dynacast = true; + let pub_options = TestRoomOptions { room: pub_room_opts, ..Default::default() }; + let sub_options = TestRoomOptions::default(); + + let mut rooms = test_rooms_with_options([pub_options, sub_options]).await?; + let (pub_room, _pub_events) = rooms.remove(0); + let (_sub_room, mut sub_events) = rooms.remove(0); + + let pub_room = Arc::new(pub_room); + let solid_params = SolidColorParams { width: 1280, height: 720, luma: 128 }; + let mut solid_track = SolidColorTrack::new(pub_room.clone(), solid_params); + solid_track.publish_without_video_send_encodings(VideoCodec::VP8).await?; + + let sub_publication: RemoteTrackPublication = timeout(Duration::from_secs(15), async { + loop { + let Some(event) = sub_events.recv().await else { + return Err(anyhow!("Event channel closed before TrackSubscribed")); + }; + if let RoomEvent::TrackSubscribed { publication, .. } = event { + return Ok(publication); + } + } + }) + .await??; + + let pub_video_track = publisher_video_track(&pub_room)?; + + let layers = wait_for_layers( + &pub_video_track, + "no encodings baseline", + Duration::from_secs(15), + |layers| layers.len() == 1 && layers[0].rid.is_empty() && layers[0].active, + ) + .await?; + assert_eq!(layers.len(), 1, "expected one default layer, got {:?}", layers); + assert!( + layers[0].rid.is_empty(), + "expected WebRTC-created default encoding to have no RID, got {:?}", + layers + ); + + drop(sub_publication); + + log::info!("dynacast no-encodings test: applying LOW=false quality update"); + pub_video_track + .set_publishing_layers_for_test(&[(PublishingLayerQuality::Low, false)]) + .map_err(|e| anyhow!("failed to disable rid-less layer: {}", e))?; + let layers = pub_video_track.publishing_layers(); + assert!( + layers.len() == 1 && layers[0].rid.is_empty() && !layers[0].active, + "expected default layer to be inactive after LOW=false update, got {:?}", + layers + ); + + log::info!("dynacast no-encodings test: applying LOW=true quality update"); + pub_video_track + .set_publishing_layers_for_test(&[(PublishingLayerQuality::Low, true)]) + .map_err(|e| anyhow!("failed to enable rid-less layer: {}", e))?; + let layers = pub_video_track.publishing_layers(); + assert!( + layers.len() == 1 && layers[0].rid.is_empty() && layers[0].active, + "expected default layer to be active after LOW=true update, got {:?}", + layers + ); + + log::info!("dynacast no-encodings test: applying HIGH=false quality update"); + pub_video_track + .set_publishing_layers_for_test(&[(PublishingLayerQuality::High, false)]) + .map_err(|e| anyhow!("failed to apply unrelated high quality update: {}", e))?; + let layers = pub_video_track.publishing_layers(); + assert!( + layers.len() == 1 && layers[0].rid.is_empty() && layers[0].active, + "expected default layer to ignore HIGH-only update, got {:?}", + layers + ); + + log::info!("dynacast no-encodings test: applying LOW=false quality update again"); + pub_video_track + .set_publishing_layers_for_test(&[(PublishingLayerQuality::Low, false)]) + .map_err(|e| anyhow!("failed to disable rid-less layer: {}", e))?; + let layers = pub_video_track.publishing_layers(); + assert!( + layers.len() == 1 && layers[0].rid.is_empty() && !layers[0].active, + "expected default layer to be inactive after second LOW=false update, got {:?}", + layers + ); + + pub_video_track + .set_publishing_layers_for_test(&[(PublishingLayerQuality::Low, true)]) + .map_err(|e| anyhow!("failed to re-enable rid-less layer: {}", e))?; + let layers = pub_video_track.publishing_layers(); + assert!( + layers.len() == 1 && layers[0].rid.is_empty() && layers[0].active, + "expected default layer to be active before cleanup, got {:?}", + layers + ); + + solid_track.unpublish().await?; + + Ok(()) +} + /// Verifies that dynacast only publishes video tracks requested by subscribers. /// /// A single publisher publishes three simulcast VP8 video tracks while two subscribers manually