Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/default-video-degradation-preferences.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"client-sdk-android": patch
---

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.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -569,11 +569,7 @@ internal constructor(
requestConfig = {
width = track.dimensions.width
height = track.dimensions.height
source = options.source?.toProto() ?: if (track.options.isScreencast) {
LivekitModels.TrackSource.SCREEN_SHARE
} else {
LivekitModels.TrackSource.CAMERA
}
source = resolveVideoTrackSource(track, options).toProto()
addAllLayers(videoLayers)

addSimulcastCodecs(
Expand Down Expand Up @@ -732,9 +728,7 @@ internal constructor(
transceiver.sortVideoCodecPreferences(finalOptions.videoCodec, capabilitiesGetter)
(track as LocalVideoTrack).codec = finalOptions.videoCodec

val rtpParameters = transceiver.sender.parameters
rtpParameters.degradationPreference = finalOptions.degradationPreference
transceiver.sender.parameters = rtpParameters
transceiver.applyDegradationPreference(finalOptions.degradationPreference, trackSource)
}

// PublisherTransportObserver.onRenegotiationNeeded() gets triggered automatically
Expand Down Expand Up @@ -1250,6 +1244,15 @@ internal constructor(
transceiver.sortVideoCodecPreferences(newOptions.videoCodec, capabilitiesGetter)
simulcastTrack.sender = transceiver.sender

// The backup codec has its own sender, so it needs the same degradation
// preference as the primary applied explicitly. Resolve the source the same
// way the primary publish did, rather than reading it back off the
// publication, so the two encoders can't disagree.
transceiver.applyDegradationPreference(
newOptions.degradationPreference,
resolveVideoTrackSource(track, newOptions),
)

engine.negotiatePublisher()
}
val publishJob = async {
Expand Down Expand Up @@ -1484,10 +1487,21 @@ abstract class BaseVideoTrackPublishOptions {
abstract val backupCodec: BackupVideoCodec?

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

Expand Down Expand Up @@ -1689,6 +1703,63 @@ internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean {
private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName)
private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName)

/**
* Resolves the [Track.Source] a video track is published under: the explicitly requested
* source if any, otherwise inferred from whether the track is backed by a screencast source.
*/
private fun resolveVideoTrackSource(track: LocalVideoTrack, options: VideoTrackPublishOptions): Track.Source {
return options.source ?: if (track.options.isScreencast) {
Track.Source.SCREEN_SHARE
} else {
Track.Source.CAMERA
}
}

/**
* Returns the appropriate degradation preference for a video track based on its source.
*
* - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication)
* - Screen share: MAINTAIN_RESOLUTION (clarity is critical for reading text/UI)
* - Other/unknown: BALANCED
*
* Any other source means the application declined to declare a motion-vs-detail intent,
* so this falls back to BALANCED, the preference the WebRTC spec mandates as the default.
* This deliberately does not defer to libwebrtc's implicit derivation, which keys off the
* native source's is_screencast flag: custom feeds report is_screencast = false regardless
* of content (see VideoFrameCapturer/BitmapFrameCapturer), so deferring would resolve to
* MAINTAIN_FRAMERATE for every custom feed rather than recovering any real intent.
*
* This is the intended behavior across LiveKit client SDKs; client-sdk-js
* (`getDefaultDegradationPreference` in publishUtils.ts) and the Rust SDK
* (`get_default_degradation_preference` in room/options.rs) use the same mapping.
*/
private fun getDefaultDegradationPreference(source: Track.Source): RtpParameters.DegradationPreference {
return when (source) {
Track.Source.CAMERA -> RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE
Track.Source.SCREEN_SHARE -> RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION
else -> RtpParameters.DegradationPreference.BALANCED

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.

A custom feed published with VideoTrackPublishOptions(source = Track.Source.UNKNOWN) lands here, and this overwrites what libwebrtc would have derived from the native source: a screencast-backed track goes MAINTAIN_RESOLUTION to BALANCED, a camera-like one MAINTAIN_FRAMERATE to BALANCED. track.options.isScreencast already carries that information. BALANCED is also the mode libwebrtc keeps behind the WebRTC-Video-BalancedDegradation field trial, with the in-tree note that it "needs to be tuned first".

Proposal:

private fun getDefaultDegradationPreference(source: Track.Source): RtpParameters.DegradationPreference? {
    return when (source) {
        Track.Source.CAMERA -> RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE
        Track.Source.SCREEN_SHARE -> RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION
        else -> null
    }
}

The KDoc bullet above changes with it. Not a blocker: JS made the same BALANCED choice deliberately, so if this is cross-SDK alignment it stands, but the argument applies there too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think BALANCED is fine for the unknown source here.

And balanced mode is set when the source is neither camera nor screen share, which requires an app to explicitly declare something else, source defaults to null and resolves to Camera/ScreenShare from isScreencast. And it only takes effect when the app hasn't set degradationPreference itself, so anyone who wants a specific behavior (including libwebrtc's implicit derivation) can name it directly.

I ran this locally in both good and constrained network conditions and personally found BALANCED to work pretty well, even without further tuning on the WebRTC side. And for a feed the app has declined to label as motion or detail, a balanced tradeoff seems like the more honest default than committing hard to either axis.

Once this PR is landed, I am going to make follow-up on other SDKs to follow what this PR is doing.

}
}

/**
* Applies [preference] to this transceiver's sender, falling back to the
* source-based default from [getDefaultDegradationPreference].
*
* Degradation preference is a property of the sender, not of the track, so every
* sender feeding from a track needs it applied separately. In particular the backup
* codec gets its own transceiver over the same rtc track, and would otherwise let
* libwebrtc resolve a preference implicitly from the native source's is_screencast
* flag, diverging from the primary encoder.
*/
private fun RtpTransceiver.applyDegradationPreference(
preference: RtpParameters.DegradationPreference?,
source: Track.Source,
) {
val rtpParameters = sender.parameters
rtpParameters.degradationPreference = preference ?: getDefaultDegradationPreference(source)
sender.parameters = rtpParameters
}

/**
* A handler that processes an RPC request and returns a string
* that will be sent back to the requester. The payload must
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,163 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
assertEquals(preference, transceiver.sender.parameters.degradationPreference)
}

@Test
fun publishCameraUsesDefaultDegradationPreference() = runTest {
connect()

room.localParticipant.publishVideoTrack(track = createLocalTrack())

val peerConnection = getPublisherPeerConnection()
val transceiver = peerConnection.transceivers.first()

assertEquals(
RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE,
transceiver.sender.parameters.degradationPreference,
)
}

@Test
fun publishScreenShareUsesDefaultDegradationPreference() = runTest {
connect()

room.localParticipant.publishVideoTrack(track = createLocalTrack(isScreencast = true))

val peerConnection = getPublisherPeerConnection()
val transceiver = peerConnection.transceivers.first()

assertEquals(
RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION,
transceiver.sender.parameters.degradationPreference,
)
}

@Test
fun publishOtherSourceUsesBalancedDegradationPreference() = runTest {
connect()

room.localParticipant.publishVideoTrack(
track = createLocalTrack(),
options = VideoTrackPublishOptions(
null,
room.videoTrackPublishDefaults,
source = Track.Source.UNKNOWN,
),
)

val peerConnection = getPublisherPeerConnection()
val transceiver = peerConnection.transceivers.first()

assertEquals(
RtpParameters.DegradationPreference.BALANCED,
transceiver.sender.parameters.degradationPreference,
)
}

@Test
fun backupCodecUsesSameDefaultDegradationPreferenceAsPrimary() = runTest {
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
videoCodec = VideoCodec.VP9.codecName,
scalabilityMode = "L3T3",
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
)

connect()
room.localParticipant.publishVideoTrack(track = createLocalTrack())

receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)

val transceivers = getPublisherPeerConnection().transceivers
assertEquals(2, transceivers.size)

// Both the primary and the backup codec sender must resolve to the same preference,
// otherwise the two encoders adapt along different axes off a shared video source.
transceivers.forEach { transceiver ->
assertEquals(
RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE,
transceiver.sender.parameters.degradationPreference,
)
}
}

@Test
fun backupCodecUsesScreenShareDefaultDegradationPreference() = runTest {
room.screenShareTrackPublishDefaults = room.screenShareTrackPublishDefaults.copy(
videoCodec = VideoCodec.VP9.codecName,
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
)

connect()
room.localParticipant.publishVideoTrack(track = createLocalTrack(isScreencast = true))

receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)

val transceivers = getPublisherPeerConnection().transceivers
assertEquals(2, transceivers.size)

transceivers.forEach { transceiver ->
assertEquals(
RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION,
transceiver.sender.parameters.degradationPreference,
)
}
}

@Test
fun backupCodecUsesExplicitDegradationPreference() = runTest {
val preference = RtpParameters.DegradationPreference.DISABLED
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
videoCodec = VideoCodec.VP9.codecName,
scalabilityMode = "L3T3",
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
degradationPreference = preference,
)

connect()
room.localParticipant.publishVideoTrack(track = createLocalTrack())

receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)

val transceivers = getPublisherPeerConnection().transceivers
assertEquals(2, transceivers.size)

transceivers.forEach { transceiver ->
assertEquals(preference, transceiver.sender.parameters.degradationPreference)
}
}

@Test
fun backupCodecDegradationPreferenceFollowsExplicitSourceNotScreencastFlag() = runTest {
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
videoCodec = VideoCodec.VP9.codecName,
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
)

connect()
// A screencast-backed track deliberately published as a camera source: both senders
// must follow the declared source rather than letting the backup fall back to the
// native source's is_screencast flag.
room.localParticipant.publishVideoTrack(
track = createLocalTrack(isScreencast = true),
options = VideoTrackPublishOptions(
null,
room.videoTrackPublishDefaults,
source = Track.Source.CAMERA,
),
)

receiveSubscribedQualityUpdate(room.localParticipant.videoTrackPublications.first().first.sid)

val transceivers = getPublisherPeerConnection().transceivers
assertEquals(2, transceivers.size)

transceivers.forEach { transceiver ->
assertEquals(
RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE,
transceiver.sender.parameters.degradationPreference,
)
}
}

@Test
fun lackOfPublishPermissionReturnsFalse() = runTest {
val noCanPublishJoin = with(TestData.JOIN.toBuilder()) {
Expand Down
Loading