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] 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(()) +}