Skip to content

Commit 114340b

Browse files
MaxHeimbrockclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 2fe0454 commit 114340b

5 files changed

Lines changed: 146 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
livekit: patch
3+
---
4+
5+
Dynacast: keep SVC (VP9/AV1) tracks active while any quality is subscribed - #1214 (@MaxHeimbrock)

livekit/src/room/mod.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1928,10 +1928,17 @@ impl RoomSession {
19281928
}
19291929
};
19301930

1931+
let video_codec = publication.publish_options().video_codec;
1932+
// SVC codecs carry all spatial layers in one encoded stream.
1933+
let is_svc = matches!(
1934+
video_codec,
1935+
crate::options::VideoCodec::VP9 | crate::options::VideoCodec::AV1
1936+
);
1937+
19311938
let qualities: Vec<proto::SubscribedQuality> = if !update.subscribed_codecs.is_empty() {
19321939
// This is the requested codec, which we also advertise in simulcast_codecs and use
19331940
// for sender codec preferences, so it should match the SFU's subscribed codec key.
1934-
let codec = publication.publish_options().video_codec.as_str().to_lowercase();
1941+
let codec = video_codec.as_str().to_lowercase();
19351942
log::info!(
19361943
"dynacast: SFU quality update for {}: subscribed_codecs={:?}, looking for codec '{}'",
19371944
track_sid,
@@ -1980,7 +1987,7 @@ impl RoomSession {
19801987
update.subscribed_qualities.clone()
19811988
};
19821989

1983-
if let Err(e) = video_track.set_publishing_layers(&qualities) {
1990+
if let Err(e) = video_track.set_publishing_layers(&qualities, is_svc) {
19841991
log::error!("dynacast: failed to set publishing layers for {}: {}", track_sid, e);
19851992
}
19861993
}

livekit/src/room/track/local_video_track.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ impl LocalVideoTrack {
355355
pub(crate) fn set_publishing_layers(
356356
&self,
357357
qualities: &[proto::SubscribedQuality],
358+
is_svc: bool,
358359
) -> RoomResult<()> {
359360
let transceiver = self.transceiver().ok_or_else(|| {
360361
RoomError::Internal("cannot set publishing layers: no transceiver".into())
@@ -368,6 +369,19 @@ impl LocalVideoTrack {
368369
return Ok(());
369370
}
370371

372+
// For SVC codecs all spatial layers ride in a single encoded stream
373+
// and the SFU selects layers server-side, so any enabled quality
374+
// keeps the whole encoding active.
375+
let qualities: Vec<proto::SubscribedQuality> =
376+
if is_svc && qualities.iter().any(|q| q.enabled) {
377+
qualities
378+
.iter()
379+
.map(|q| proto::SubscribedQuality { enabled: true, ..q.clone() })
380+
.collect()
381+
} else {
382+
qualities.to_vec()
383+
};
384+
371385
let mut changed = false;
372386
for encoding in &mut params.encodings {
373387
// The SFU addresses layers by spatial index (0 = Low), so a

livekit/tests/common/e2e/video.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ impl SolidColorTrack {
6363
}
6464

6565
pub async fn publish(&mut self, codec: VideoCodec, simulcast: bool) -> RoomResult<()> {
66+
self.publish_with_options(TrackPublishOptions {
67+
video_codec: codec,
68+
simulcast,
69+
..Default::default()
70+
})
71+
.await
72+
}
73+
74+
pub async fn publish_with_options(&mut self, options: TrackPublishOptions) -> RoomResult<()> {
6675
let (close_tx, close_rx) = oneshot::channel();
6776
let track = LocalVideoTrack::create_video_track(
6877
"solid-color-track",
@@ -72,10 +81,7 @@ impl SolidColorTrack {
7281
tokio::spawn(Self::track_task(close_rx, self.rtc_source.clone(), self.params.clone()));
7382
self.room
7483
.local_participant()
75-
.publish_track(
76-
LocalTrack::Video(track.clone()),
77-
TrackPublishOptions { video_codec: codec, simulcast, ..Default::default() },
78-
)
84+
.publish_track(LocalTrack::Video(track.clone()), options)
7985
.await?;
8086
let handle = TrackHandle { close_tx, track, task };
8187
self.handle = Some(handle);

livekit/tests/dynacast_test.rs

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use {
2121
TestRoomOptions,
2222
},
2323
livekit::{
24-
options::VideoCodec,
24+
options::{TrackPublishOptions, VideoCodec},
2525
prelude::*,
2626
track::{PublishingLayerQuality, VideoQuality},
2727
},
@@ -428,3 +428,110 @@ async fn test_dynacast_multiple_subscribers_only_publish_requested_tracks() -> R
428428

429429
Ok(())
430430
}
431+
432+
/// Verifies dynacast behavior for an SVC track (VP9, L3T3_KEY).
433+
///
434+
/// SVC tracks carry all spatial layers in a single encoded stream and the SFU
435+
/// selects layers server-side, so:
436+
/// 1. Subscriber quality requests must never deactivate the encoding while at
437+
/// least one quality is subscribed (any-enabled rule).
438+
/// 2. Unsubscribing the last subscriber deactivates the encoding.
439+
/// 3. Resubscribing reactivates it.
440+
#[cfg(feature = "__lk-e2e-test")]
441+
#[test_log::test(tokio::test)]
442+
async fn test_dynacast_svc() -> Result<()> {
443+
let mut pub_room_opts = RoomOptions::default();
444+
pub_room_opts.dynacast = true;
445+
let pub_options = TestRoomOptions { room: pub_room_opts, ..Default::default() };
446+
let sub_options = TestRoomOptions::default();
447+
448+
let mut rooms = test_rooms_with_options([pub_options, sub_options]).await?;
449+
let (pub_room, _pub_events) = rooms.remove(0);
450+
let (_sub_room, mut sub_events) = rooms.remove(0);
451+
452+
let pub_room = Arc::new(pub_room);
453+
let solid_params = SolidColorParams { width: 1280, height: 720, luma: 128 };
454+
let mut solid_track = SolidColorTrack::new(pub_room.clone(), solid_params);
455+
solid_track
456+
.publish_with_options(TrackPublishOptions {
457+
video_codec: VideoCodec::VP9,
458+
simulcast: false,
459+
scalability_mode: Some("L3T3_KEY".to_string()),
460+
..Default::default()
461+
})
462+
.await?;
463+
464+
let sub_publication: RemoteTrackPublication = timeout(Duration::from_secs(15), async {
465+
loop {
466+
let Some(event) = sub_events.recv().await else {
467+
return Err(anyhow!("Event channel closed before TrackSubscribed"));
468+
};
469+
if let RoomEvent::TrackSubscribed { publication, .. } = event {
470+
return Ok(publication);
471+
}
472+
}
473+
})
474+
.await??;
475+
476+
let pub_video_track = publisher_video_track(&pub_room)?;
477+
478+
// --- Baseline: the single SVC encoding is active ---
479+
let layers =
480+
wait_for_layers(&pub_video_track, "svc baseline", Duration::from_secs(15), |layers| {
481+
layers.len() == 1 && layers[0].active
482+
})
483+
.await?;
484+
log::info!("dynacast svc baseline layers: {:?}", layers);
485+
486+
// --- Request LOW quality: the SVC encoding must stay active ---
487+
log::info!("dynacast svc test: requesting LOW quality");
488+
sub_publication.set_video_quality(VideoQuality::Low);
489+
490+
// The resulting quality update arrives asynchronously; poll to make sure
491+
// the encoding never gets deactivated by it.
492+
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
493+
while tokio::time::Instant::now() < deadline {
494+
let layers = pub_video_track.publishing_layers();
495+
assert!(
496+
!layers.is_empty() && layers.iter().all(|layer| layer.active),
497+
"SVC encoding must stay active after LOW request, got {:?}",
498+
layers
499+
);
500+
time::sleep(Duration::from_millis(250)).await;
501+
}
502+
503+
// --- Request HIGH quality again: still active ---
504+
log::info!("dynacast svc test: requesting HIGH quality");
505+
sub_publication.set_video_quality(VideoQuality::High);
506+
507+
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
508+
while tokio::time::Instant::now() < deadline {
509+
let layers = pub_video_track.publishing_layers();
510+
assert!(
511+
!layers.is_empty() && layers.iter().all(|layer| layer.active),
512+
"SVC encoding must stay active after HIGH request, got {:?}",
513+
layers
514+
);
515+
time::sleep(Duration::from_millis(250)).await;
516+
}
517+
518+
// --- Unsubscribe: with no subscribers left the encoding is deactivated ---
519+
log::info!("dynacast svc test: unsubscribing");
520+
sub_publication.set_subscribed(false);
521+
522+
wait_for_layers(&pub_video_track, "svc unsubscribed", Duration::from_secs(30), |layers| {
523+
!layers.is_empty() && layers.iter().all(|layer| !layer.active)
524+
})
525+
.await?;
526+
527+
// --- Resubscribe: the encoding comes back ---
528+
log::info!("dynacast svc test: resubscribing");
529+
sub_publication.set_subscribed(true);
530+
531+
wait_for_layers(&pub_video_track, "svc resubscribed", Duration::from_secs(30), |layers| {
532+
!layers.is_empty() && layers.iter().all(|layer| layer.active)
533+
})
534+
.await?;
535+
536+
Ok(())
537+
}

0 commit comments

Comments
 (0)