Skip to content

Commit cd99c51

Browse files
Change default degradation preference by video source (#991)
* Change default degradation preference by video source * Add changeset for video degradation preferences * addressed the backup codec comment
1 parent b733c47 commit cd99c51

3 files changed

Lines changed: 246 additions & 11 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"client-sdk-android": patch
3+
---
4+
5+
Use source-specific default video degradation preferences: camera tracks default to maintaining framerate, screen share tracks default to maintaining resolution, and other video sources default to balanced. This matches client-sdk-js. Video tracks published with an explicit `source` other than camera or screen share now use balanced rather than WebRTC's implicit choice; set `degradationPreference` on the publish options to override.
6+
7+
The resolved preference is now also applied to the backup codec's sender. Previously only the primary encoder was configured and the backup encoder let libwebrtc derive a preference implicitly, so the two encoders could adapt along different axes off the same video source.

livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -592,11 +592,7 @@ internal constructor(
592592
requestConfig = {
593593
width = track.dimensions.width
594594
height = track.dimensions.height
595-
source = options.source?.toProto() ?: if (track.options.isScreencast) {
596-
LivekitModels.TrackSource.SCREEN_SHARE
597-
} else {
598-
LivekitModels.TrackSource.CAMERA
599-
}
595+
source = resolveVideoTrackSource(track, options).toProto()
600596
addAllLayers(videoLayers)
601597

602598
addSimulcastCodecs(
@@ -772,9 +768,7 @@ internal constructor(
772768
transceiver.sortVideoCodecPreferences(finalOptions.videoCodec, capabilitiesGetter)
773769
(track as LocalVideoTrack).codec = finalOptions.videoCodec
774770

775-
val rtpParameters = transceiver.sender.parameters
776-
rtpParameters.degradationPreference = finalOptions.degradationPreference
777-
transceiver.sender.parameters = rtpParameters
771+
transceiver.applyDegradationPreference(finalOptions.degradationPreference, trackSource)
778772
}
779773

780774
// PublisherTransportObserver.onRenegotiationNeeded() gets triggered automatically
@@ -1318,6 +1312,15 @@ internal constructor(
13181312
transceiver.sortVideoCodecPreferences(newOptions.videoCodec, capabilitiesGetter)
13191313
simulcastTrack.sender = transceiver.sender
13201314

1315+
// The backup codec has its own sender, so it needs the same degradation
1316+
// preference as the primary applied explicitly. Resolve the source the same
1317+
// way the primary publish did, rather than reading it back off the
1318+
// publication, so the two encoders can't disagree.
1319+
transceiver.applyDegradationPreference(
1320+
newOptions.degradationPreference,
1321+
resolveVideoTrackSource(track, newOptions),
1322+
)
1323+
13211324
engine.negotiatePublisher()
13221325
}
13231326
val publishJob = async {
@@ -1552,10 +1555,21 @@ abstract class BaseVideoTrackPublishOptions {
15521555
abstract val backupCodec: BackupVideoCodec?
15531556

15541557
/**
1555-
* When bandwidth is constrained, this preference indicates which is preferred
1556-
* between degrading resolution vs. framerate.
1558+
* Controls how the encoder trades off between resolution and framerate
1559+
* when bandwidth is constrained.
15571560
*
1558-
* null value indicates default value (maintain framerate).
1561+
* - MAINTAIN_FRAMERATE: Prioritizes framerate, reduces resolution if needed
1562+
* - MAINTAIN_RESOLUTION: Prioritizes resolution, drops frames if needed
1563+
* - BALANCED: Balances between both
1564+
*
1565+
* If not set (null), the SDK uses defaults based on track source:
1566+
* - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication)
1567+
* - Screen share: MAINTAIN_RESOLUTION (clarity is critical for text/UI)
1568+
* - Other/unknown: BALANCED
1569+
*
1570+
* Note that a preference is always applied to video senders, so leaving this null
1571+
* selects the source-based default above rather than deferring to WebRTC's own
1572+
* implicit choice.
15591573
*/
15601574
abstract val degradationPreference: RtpParameters.DegradationPreference?
15611575

@@ -1757,6 +1771,63 @@ internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean {
17571771
private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName)
17581772
private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName)
17591773

1774+
/**
1775+
* Resolves the [Track.Source] a video track is published under: the explicitly requested
1776+
* source if any, otherwise inferred from whether the track is backed by a screencast source.
1777+
*/
1778+
private fun resolveVideoTrackSource(track: LocalVideoTrack, options: VideoTrackPublishOptions): Track.Source {
1779+
return options.source ?: if (track.options.isScreencast) {
1780+
Track.Source.SCREEN_SHARE
1781+
} else {
1782+
Track.Source.CAMERA
1783+
}
1784+
}
1785+
1786+
/**
1787+
* Returns the appropriate degradation preference for a video track based on its source.
1788+
*
1789+
* - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication)
1790+
* - Screen share: MAINTAIN_RESOLUTION (clarity is critical for reading text/UI)
1791+
* - Other/unknown: BALANCED
1792+
*
1793+
* Any other source means the application declined to declare a motion-vs-detail intent,
1794+
* so this falls back to BALANCED, the preference the WebRTC spec mandates as the default.
1795+
* This deliberately does not defer to libwebrtc's implicit derivation, which keys off the
1796+
* native source's is_screencast flag: custom feeds report is_screencast = false regardless
1797+
* of content (see VideoFrameCapturer/BitmapFrameCapturer), so deferring would resolve to
1798+
* MAINTAIN_FRAMERATE for every custom feed rather than recovering any real intent.
1799+
*
1800+
* This is the intended behavior across LiveKit client SDKs; client-sdk-js
1801+
* (`getDefaultDegradationPreference` in publishUtils.ts) and the Rust SDK
1802+
* (`get_default_degradation_preference` in room/options.rs) use the same mapping.
1803+
*/
1804+
private fun getDefaultDegradationPreference(source: Track.Source): RtpParameters.DegradationPreference {
1805+
return when (source) {
1806+
Track.Source.CAMERA -> RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE
1807+
Track.Source.SCREEN_SHARE -> RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION
1808+
else -> RtpParameters.DegradationPreference.BALANCED
1809+
}
1810+
}
1811+
1812+
/**
1813+
* Applies [preference] to this transceiver's sender, falling back to the
1814+
* source-based default from [getDefaultDegradationPreference].
1815+
*
1816+
* Degradation preference is a property of the sender, not of the track, so every
1817+
* sender feeding from a track needs it applied separately. In particular the backup
1818+
* codec gets its own transceiver over the same rtc track, and would otherwise let
1819+
* libwebrtc resolve a preference implicitly from the native source's is_screencast
1820+
* flag, diverging from the primary encoder.
1821+
*/
1822+
private fun RtpTransceiver.applyDegradationPreference(
1823+
preference: RtpParameters.DegradationPreference?,
1824+
source: Track.Source,
1825+
) {
1826+
val rtpParameters = sender.parameters
1827+
rtpParameters.degradationPreference = preference ?: getDefaultDegradationPreference(source)
1828+
sender.parameters = rtpParameters
1829+
}
1830+
17601831
/**
17611832
* A handler that processes an RPC request and returns a string
17621833
* that will be sent back to the requester. The payload must

livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,163 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
841841
assertEquals(preference, transceiver.sender.parameters.degradationPreference)
842842
}
843843

844+
@Test
845+
fun publishCameraUsesDefaultDegradationPreference() = runTest {
846+
connect()
847+
848+
room.localParticipant.publishVideoTrack(track = createLocalTrack())
849+
850+
val peerConnection = getPublisherPeerConnection()
851+
val transceiver = peerConnection.transceivers.first()
852+
853+
assertEquals(
854+
RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE,
855+
transceiver.sender.parameters.degradationPreference,
856+
)
857+
}
858+
859+
@Test
860+
fun publishScreenShareUsesDefaultDegradationPreference() = runTest {
861+
connect()
862+
863+
room.localParticipant.publishVideoTrack(track = createLocalTrack(isScreencast = true))
864+
865+
val peerConnection = getPublisherPeerConnection()
866+
val transceiver = peerConnection.transceivers.first()
867+
868+
assertEquals(
869+
RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION,
870+
transceiver.sender.parameters.degradationPreference,
871+
)
872+
}
873+
874+
@Test
875+
fun publishOtherSourceUsesBalancedDegradationPreference() = runTest {
876+
connect()
877+
878+
room.localParticipant.publishVideoTrack(
879+
track = createLocalTrack(),
880+
options = VideoTrackPublishOptions(
881+
null,
882+
room.videoTrackPublishDefaults,
883+
source = Track.Source.UNKNOWN,
884+
),
885+
)
886+
887+
val peerConnection = getPublisherPeerConnection()
888+
val transceiver = peerConnection.transceivers.first()
889+
890+
assertEquals(
891+
RtpParameters.DegradationPreference.BALANCED,
892+
transceiver.sender.parameters.degradationPreference,
893+
)
894+
}
895+
896+
@Test
897+
fun backupCodecUsesSameDefaultDegradationPreferenceAsPrimary() = runTest {
898+
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
899+
videoCodec = VideoCodec.VP9.codecName,
900+
scalabilityMode = "L3T3",
901+
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
902+
)
903+
904+
connect()
905+
room.localParticipant.publishVideoTrack(track = createLocalTrack())
906+
907+
receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)
908+
909+
val transceivers = getPublisherPeerConnection().transceivers
910+
assertEquals(2, transceivers.size)
911+
912+
// Both the primary and the backup codec sender must resolve to the same preference,
913+
// otherwise the two encoders adapt along different axes off a shared video source.
914+
transceivers.forEach { transceiver ->
915+
assertEquals(
916+
RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE,
917+
transceiver.sender.parameters.degradationPreference,
918+
)
919+
}
920+
}
921+
922+
@Test
923+
fun backupCodecUsesScreenShareDefaultDegradationPreference() = runTest {
924+
room.screenShareTrackPublishDefaults = room.screenShareTrackPublishDefaults.copy(
925+
videoCodec = VideoCodec.VP9.codecName,
926+
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
927+
)
928+
929+
connect()
930+
room.localParticipant.publishVideoTrack(track = createLocalTrack(isScreencast = true))
931+
932+
receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)
933+
934+
val transceivers = getPublisherPeerConnection().transceivers
935+
assertEquals(2, transceivers.size)
936+
937+
transceivers.forEach { transceiver ->
938+
assertEquals(
939+
RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION,
940+
transceiver.sender.parameters.degradationPreference,
941+
)
942+
}
943+
}
944+
945+
@Test
946+
fun backupCodecUsesExplicitDegradationPreference() = runTest {
947+
val preference = RtpParameters.DegradationPreference.DISABLED
948+
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
949+
videoCodec = VideoCodec.VP9.codecName,
950+
scalabilityMode = "L3T3",
951+
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
952+
degradationPreference = preference,
953+
)
954+
955+
connect()
956+
room.localParticipant.publishVideoTrack(track = createLocalTrack())
957+
958+
receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)
959+
960+
val transceivers = getPublisherPeerConnection().transceivers
961+
assertEquals(2, transceivers.size)
962+
963+
transceivers.forEach { transceiver ->
964+
assertEquals(preference, transceiver.sender.parameters.degradationPreference)
965+
}
966+
}
967+
968+
@Test
969+
fun backupCodecDegradationPreferenceFollowsExplicitSourceNotScreencastFlag() = runTest {
970+
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
971+
videoCodec = VideoCodec.VP9.codecName,
972+
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
973+
)
974+
975+
connect()
976+
// A screencast-backed track deliberately published as a camera source: both senders
977+
// must follow the declared source rather than letting the backup fall back to the
978+
// native source's is_screencast flag.
979+
room.localParticipant.publishVideoTrack(
980+
track = createLocalTrack(isScreencast = true),
981+
options = VideoTrackPublishOptions(
982+
null,
983+
room.videoTrackPublishDefaults,
984+
source = Track.Source.CAMERA,
985+
),
986+
)
987+
988+
receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)
989+
990+
val transceivers = getPublisherPeerConnection().transceivers
991+
assertEquals(2, transceivers.size)
992+
993+
transceivers.forEach { transceiver ->
994+
assertEquals(
995+
RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE,
996+
transceiver.sender.parameters.degradationPreference,
997+
)
998+
}
999+
}
1000+
8441001
@Test
8451002
fun lackOfPublishPermissionReturnsFalse() = runTest {
8461003
val noCanPublishJoin = with(TestData.JOIN.toBuilder()) {

0 commit comments

Comments
 (0)