Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dynacast_svc_any_enabled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
livekit: patch
---

Dynacast: keep SVC (VP9/AV1) tracks active while any quality is subscribed - #1214 (@MaxHeimbrock)
11 changes: 9 additions & 2 deletions livekit/src/room/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion(non-blocking): Consider breaking this out into a const helper function.

video_codec,
crate::options::VideoCodec::VP9 | crate::options::VideoCodec::AV1
);

let qualities: Vec<proto::SubscribedQuality> = 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,
Expand Down Expand Up @@ -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);
}
}
Expand Down
14 changes: 14 additions & 0 deletions livekit/src/room/track/local_video_track.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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<proto::SubscribedQuality> =
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
Expand Down
14 changes: 10 additions & 4 deletions livekit/tests/common/e2e/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down
109 changes: 108 additions & 1 deletion livekit/tests/dynacast_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use {
TestRoomOptions,
},
livekit::{
options::VideoCodec,
options::{TrackPublishOptions, VideoCodec},
prelude::*,
track::{PublishingLayerQuality, VideoQuality},
},
Expand Down Expand Up @@ -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);
Comment on lines +446 to +449

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: You can use rooms.pop() here.

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