From bf02dde7554094823e2827486a49aead21cf65f9 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:23:30 +0200 Subject: [PATCH 1/3] Fix for dynacast error --- livekit/src/room/track/local_video_track.rs | 25 +++++++++++---------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/livekit/src/room/track/local_video_track.rs b/livekit/src/room/track/local_video_track.rs index 90c174f42..fe356454a 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(", ")); From 2fe0454f59e95db4014d5c4fd1259ecf6a129e25 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:05:44 +0200 Subject: [PATCH 2/3] Add changeset --- .changeset/fix_for_dynacast_error.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fix_for_dynacast_error.md 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) From 114340b341292bd18c8a3f7a773d1fb896cbac5b Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:03:16 +0200 Subject: [PATCH 3/3] Dynacast: keep SVC tracks active while any quality is subscribed Port the SVC special case from client-sdk-js: for SVC codecs (VP9/AV1) all spatial layers ride in a single encoded stream and the SFU selects layers server-side, so any enabled quality in a SubscribedQualityUpdate must keep the whole encoding active instead of being matched per-layer. Adds an e2e test publishing VP9 L3T3_KEY that verifies the encoding stays active across quality requests, deactivates when the last subscriber leaves, and reactivates on resubscribe. Co-Authored-By: Claude Fable 5 --- .changeset/dynacast_svc_any_enabled.md | 5 + livekit/src/room/mod.rs | 11 +- livekit/src/room/track/local_video_track.rs | 14 +++ livekit/tests/common/e2e/video.rs | 14 ++- livekit/tests/dynacast_test.rs | 109 +++++++++++++++++++- 5 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 .changeset/dynacast_svc_any_enabled.md diff --git a/.changeset/dynacast_svc_any_enabled.md b/.changeset/dynacast_svc_any_enabled.md new file mode 100644 index 000000000..73f8e03b4 --- /dev/null +++ b/.changeset/dynacast_svc_any_enabled.md @@ -0,0 +1,5 @@ +--- +livekit: patch +--- + +Dynacast: keep SVC (VP9/AV1) tracks active while any quality is subscribed - #1214 (@MaxHeimbrock) diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 6cfd57605..9d00a590e 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -1928,10 +1928,17 @@ impl RoomSession { } }; + let video_codec = publication.publish_options().video_codec; + // SVC codecs carry all spatial layers in one encoded stream. + let is_svc = matches!( + video_codec, + crate::options::VideoCodec::VP9 | crate::options::VideoCodec::AV1 + ); + let qualities: Vec = if !update.subscribed_codecs.is_empty() { // This is the requested codec, which we also advertise in simulcast_codecs and use // for sender codec preferences, so it should match the SFU's subscribed codec key. - let codec = publication.publish_options().video_codec.as_str().to_lowercase(); + let codec = video_codec.as_str().to_lowercase(); log::info!( "dynacast: SFU quality update for {}: subscribed_codecs={:?}, looking for codec '{}'", track_sid, @@ -1980,7 +1987,7 @@ impl RoomSession { update.subscribed_qualities.clone() }; - if let Err(e) = video_track.set_publishing_layers(&qualities) { + if let Err(e) = video_track.set_publishing_layers(&qualities, is_svc) { log::error!("dynacast: failed to set publishing layers for {}: {}", track_sid, e); } } diff --git a/livekit/src/room/track/local_video_track.rs b/livekit/src/room/track/local_video_track.rs index fe356454a..20041e4b9 100644 --- a/livekit/src/room/track/local_video_track.rs +++ b/livekit/src/room/track/local_video_track.rs @@ -355,6 +355,7 @@ impl LocalVideoTrack { pub(crate) fn set_publishing_layers( &self, qualities: &[proto::SubscribedQuality], + is_svc: bool, ) -> RoomResult<()> { let transceiver = self.transceiver().ok_or_else(|| { RoomError::Internal("cannot set publishing layers: no transceiver".into()) @@ -368,6 +369,19 @@ impl LocalVideoTrack { return Ok(()); } + // For SVC codecs all spatial layers ride in a single encoded stream + // and the SFU selects layers server-side, so any enabled quality + // keeps the whole encoding active. + let qualities: Vec = + if is_svc && qualities.iter().any(|q| q.enabled) { + qualities + .iter() + .map(|q| proto::SubscribedQuality { enabled: true, ..q.clone() }) + .collect() + } else { + qualities.to_vec() + }; + let mut changed = false; for encoding in &mut params.encodings { // The SFU addresses layers by spatial index (0 = Low), so a diff --git a/livekit/tests/common/e2e/video.rs b/livekit/tests/common/e2e/video.rs index fac27cac1..583dbf770 100644 --- a/livekit/tests/common/e2e/video.rs +++ b/livekit/tests/common/e2e/video.rs @@ -63,6 +63,15 @@ 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 + } + + pub 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", @@ -72,10 +81,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(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..7cba99724 100644 --- a/livekit/tests/dynacast_test.rs +++ b/livekit/tests/dynacast_test.rs @@ -21,7 +21,7 @@ use { TestRoomOptions, }, livekit::{ - options::VideoCodec, + options::{TrackPublishOptions, VideoCodec}, prelude::*, track::{PublishingLayerQuality, VideoQuality}, }, @@ -428,3 +428,110 @@ async fn test_dynacast_multiple_subscribers_only_publish_requested_tracks() -> R Ok(()) } + +/// Verifies dynacast behavior for an SVC track (VP9, L3T3_KEY). +/// +/// SVC tracks carry all spatial layers in a single encoded stream and the SFU +/// selects layers server-side, so: +/// 1. Subscriber quality requests must never deactivate the encoding while at +/// least one quality is subscribed (any-enabled rule). +/// 2. Unsubscribing the last subscriber deactivates the encoding. +/// 3. Resubscribing reactivates it. +#[cfg(feature = "__lk-e2e-test")] +#[test_log::test(tokio::test)] +async fn test_dynacast_svc() -> 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_with_options(TrackPublishOptions { + video_codec: VideoCodec::VP9, + simulcast: false, + scalability_mode: Some("L3T3_KEY".to_string()), + ..Default::default() + }) + .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)?; + + // --- Baseline: the single SVC encoding is active --- + let layers = + wait_for_layers(&pub_video_track, "svc baseline", Duration::from_secs(15), |layers| { + layers.len() == 1 && layers[0].active + }) + .await?; + log::info!("dynacast svc baseline layers: {:?}", layers); + + // --- Request LOW quality: the SVC encoding must stay active --- + log::info!("dynacast svc test: requesting LOW quality"); + sub_publication.set_video_quality(VideoQuality::Low); + + // The resulting quality update arrives asynchronously; poll to make sure + // the encoding never gets deactivated by it. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + let layers = pub_video_track.publishing_layers(); + assert!( + !layers.is_empty() && layers.iter().all(|layer| layer.active), + "SVC encoding must stay active after LOW request, got {:?}", + layers + ); + time::sleep(Duration::from_millis(250)).await; + } + + // --- Request HIGH quality again: still active --- + log::info!("dynacast svc test: requesting HIGH quality"); + sub_publication.set_video_quality(VideoQuality::High); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while tokio::time::Instant::now() < deadline { + let layers = pub_video_track.publishing_layers(); + assert!( + !layers.is_empty() && layers.iter().all(|layer| layer.active), + "SVC encoding must stay active after HIGH request, got {:?}", + layers + ); + time::sleep(Duration::from_millis(250)).await; + } + + // --- Unsubscribe: with no subscribers left the encoding is deactivated --- + log::info!("dynacast svc test: unsubscribing"); + sub_publication.set_subscribed(false); + + wait_for_layers(&pub_video_track, "svc unsubscribed", Duration::from_secs(30), |layers| { + !layers.is_empty() && layers.iter().all(|layer| !layer.active) + }) + .await?; + + // --- Resubscribe: the encoding comes back --- + log::info!("dynacast svc test: resubscribing"); + sub_publication.set_subscribed(true); + + wait_for_layers(&pub_video_track, "svc resubscribed", Duration::from_secs(30), |layers| { + !layers.is_empty() && layers.iter().all(|layer| layer.active) + }) + .await?; + + Ok(()) +}