Skip to content

Change default degradation preference by video source - #991

Merged
xianshijing-lk merged 3 commits into
mainfrom
sxian/CLT-3068/change_default_degrade_preference_mode_based_on_video_source
Aug 7, 2026
Merged

Change default degradation preference by video source#991
xianshijing-lk merged 3 commits into
mainfrom
sxian/CLT-3068/change_default_degrade_preference_mode_based_on_video_source

Conversation

@xianshijing-lk

Copy link
Copy Markdown
Contributor

Summary

  • Default video degradation preference by track source when degradationPreference is not explicitly set.
  • Camera tracks now default to MAINTAIN_FRAMERATE.
  • Screen share tracks now default to MAINTAIN_RESOLUTION.
  • Other video sources default to BALANCED.
  • Preserve explicit degradationPreference overrides.
  • Add tests for camera and screen share defaults.

Testing

  • git diff --check
  • Focused Gradle test attempted, but compile failed due existing generated protocol reference issues (AGENT_ERROR, PUBLISH_DATA_TRACK_RESPONSE, clientProtocol, etc.).

@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 85a76ee

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
client-sdk-android Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

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.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@xianshijing-lk xianshijing-lk left a comment

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.

@adrian-niculescu @davidliu , I broke down default degradation preference changes from #973 to keep the discussion more focused.

Please review this PR.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Diffuse output:

OLD: diffuse-source-file
NEW: livekit-android-sdk-release.aar

 AAR      │ old      │ new      │ diff      
──────────┼──────────┼──────────┼───────────
      jar │  2.7 MiB │  2.7 MiB │ +16.7 KiB 
 manifest │  1.5 KiB │  1.5 KiB │       0 B 
 lint-jar │ 12.7 KiB │ 12.7 KiB │       0 B 
    other │  1.9 KiB │  1.9 KiB │       0 B 
──────────┼──────────┼──────────┼───────────
    total │  2.7 MiB │  2.7 MiB │ +16.7 KiB 

 JAR     │ old   │ new   │ diff          
─────────┼───────┼───────┼───────────────
 classes │  1504 │  1512 │  +8 (+10 -2)  
 methods │ 20222 │ 20271 │ +49 (+66 -17) 
  fields │  5166 │  5212 │ +46 (+52 -6)
AAR
 size    │ diff      │ path          
─────────┼───────────┼───────────────
 2.7 MiB │ +16.7 KiB │ ∆ classes.jar 
─────────┼───────────┼───────────────
 2.7 MiB │ +16.7 KiB │ (total)
JAR
CLASSES:

   old  │ new  │ diff        
  ──────┼──────┼─────────────
   1504 │ 1512 │ +8 (+10 -2) 
  
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_1
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_transceiver_1
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1_1
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1
  + io.livekit.android.room.SenderTransceiverHandle
  + io.livekit.android.room.SignalSessionState
  + io.livekit.android.room.participant.LocalParticipant_publishTrackImpl_negotiate_createdTransceiver_1
  + io.livekit.android.room.participant.LocalParticipantKt_WhenMappings
  + io.livekit.android.token.DevelopmentTokenServerOptions
  + io.livekit.android.token.DevelopmentTokenSource
  
  - io.livekit.android.room.RTCEngine_createSenderTransceiver_2
  - io.livekit.android.token.SandboxTokenSource
  

METHODS:

   old   │ new   │ diff          
  ───────┼───────┼───────────────
   20222 │ 20271 │ +49 (+66 -17) 
  
  + io.livekit.android.room.RTCEngine access_getSignalSessionState_p(RTCEngine) → SignalSessionState
  + io.livekit.android.room.RTCEngine access_setSignalSessionState_p(RTCEngine, SignalSessionState)
  + io.livekit.android.room.RTCEngine endSignalSession()
  + io.livekit.android.room.RTCEngine rollbackSenderTransceiver_livekit_android_sdk_release(SenderTransceiverHandle, boolean) → boolean
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_1 <init>(RTCEngine, Continuation)
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_1 invokeSuspend(Object) → Object
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_transceiver_1 <init>(MediaStreamTrack, RtpTransceiver_RtpTransceiverInit, Continuation)
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_transceiver_1 create(Object, Continuation) → Continuation
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_transceiver_1 invoke(Object, Object) → Object
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_transceiver_1 invoke(PeerConnection, Continuation) → Object
  + io.livekit.android.room.RTCEngine_createSenderTransceiver_transceiver_1 invokeSuspend(Object) → Object
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1 <init>(PeerConnectionTransport, RTCEngine, SenderTransceiverHandle, boolean, Continuation)
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1 create(Object, Continuation) → Continuation
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1 invoke(Object, Object) → Object
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1 invoke(CoroutineScope, Continuation) → Object
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1 invokeSuspend(Object) → Object
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1_1 <init>(RTCEngine, PeerConnectionTransport, SenderTransceiverHandle, boolean, Continuation)
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1_1 create(Object, Continuation) → Continuation
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1_1 invoke(Object, Object) → Object
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1_1 invoke(PeerConnection, Continuation) → Object
  + io.livekit.android.room.RTCEngine_rollbackSenderTransceiver_1_1 invokeSuspend(Object) → Object
  + io.livekit.android.room.SenderTransceiverHandle <init>(PeerConnectionTransport, RtpTransceiver, SignalSessionState)
  + io.livekit.android.room.SenderTransceiverHandle getPublisher_livekit_android_sdk_release() → PeerConnectionTransport
  + io.livekit.android.room.SenderTransceiverHandle getSignalSessionState_livekit_android_sdk_release() → SignalSessionState
  + io.livekit.android.room.SenderTransceiverHandle getTransceiver_livekit_android_sdk_release() → RtpTransceiver
  + io.livekit.android.room.SignalSessionState <init>(boolean)
  + io.livekit.android.room.SignalSessionState getEnded() → boolean
  + io.livekit.android.room.participant.LocalParticipant access_publishTrackImpl_negotiate(LocalParticipant, Ref_ObjectRef, Track, Ref_ObjectRef, Ref_ObjectRef, String, Track_Source, Ref_ObjectRef, LocalParticipant_PublishListener, Continuation) → Object
  + io.livekit.android.room.participant.LocalParticipant access_setTrackEnabled_lambda_7_notifyStopped(AtomicBoolean, ScreenCaptureParams)
  + io.livekit.android.room.participant.LocalParticipant publishTrackImpl_negotiate(LocalParticipant, Ref_ObjectRef, Track, Ref_ObjectRef, Ref_Obje
...✂

@adrian-niculescu adrian-niculescu left a comment

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.

The backup codec sender never gets the preference.

publishAdditionalCodecForTrack creates a second transceiver over the same rtcTrack and never touches sender.parameters, so that sender keeps resolving implicitly from the native source's is_screencast. Consequences:

  • Camera and screen share stay media-equivalent across the two encoders as long as options.source agrees with track.options.isScreencast. A screencast-backed track published with source = CAMERA now diverges: primary MAINTAIN_FRAMERATE, backup MAINTAIN_RESOLUTION.
  • An application-supplied degradationPreference reaches the primary encoder only.
  • The new BALANCED fallback reaches the primary encoder only.

The explicit-override half predates this PR and JS has the same gap, but this PR is what makes a resolved preference a value worth carrying, so applying it to the backup sender belongs here.

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.

@xianshijing-lk

Copy link
Copy Markdown
Contributor Author

The backup codec sender never gets the preference.

publishAdditionalCodecForTrack creates a second transceiver over the same rtcTrack and never touches sender.parameters, so that sender keeps resolving implicitly from the native source's is_screencast. Consequences:

  • Camera and screen share stay media-equivalent across the two encoders as long as options.source agrees with track.options.isScreencast. A screencast-backed track published with source = CAMERA now diverges: primary MAINTAIN_FRAMERATE, backup MAINTAIN_RESOLUTION.
  • An application-supplied degradationPreference reaches the primary encoder only.
  • The new BALANCED fallback reaches the primary encoder only.

The explicit-override half predates this PR and JS has the same gap, but this PR is what makes a resolved preference a value worth carrying, so applying it to the backup sender belongs here.

Good catch. Confirmed: degradation preference is a sender level property (a top level field on RtpParameters, not per encoding), and publishAdditionalCodecForTrack adds a second transceiver over the same rtc track, so the backup codec has its own sender with its own parameters, which we were never setting. Worth noting both senders sink from the same VideoSource, and VideoBroadcaster::UpdateWants takes the MIN of max_pixel_count and max_framerate_fps across sinks, so a diverging backup drags its restriction onto the primary too.

Applied the resolved preference to the backup sender, resolving the source the same way the primary publish does rather than reading it back off the publication, so the two can't disagree. Added tests asserting both senders match for the default camera, default screen share, explicit-override, and screencast-backed-published-as-camera cases.

I will port the same fix to other sdks once landing this PR.

@xianshijing-lk

Copy link
Copy Markdown
Contributor Author

Hi @adrian-niculescu , thanks for the comments, can you take another look ?

@adrian-niculescu adrian-niculescu left a comment

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.

Read the changes and your comments. LGTM, thanks!

@xianshijing-lk

Copy link
Copy Markdown
Contributor Author

Thanks @adrian-niculescu and @davidliu

@xianshijing-lk
xianshijing-lk merged commit cd99c51 into main Aug 7, 2026
5 checks passed
@xianshijing-lk
xianshijing-lk deleted the sxian/CLT-3068/change_default_degrade_preference_mode_based_on_video_source branch August 7, 2026 06:14
@davidliu davidliu mentioned this pull request Aug 7, 2026
hiroshihorie added a commit to livekit/client-sdk-flutter that referenced this pull request Aug 7, 2026
…ackup codec (#1155)

Aligns Flutter with the behavior landed in client-sdk-android
(livekit/client-sdk-android#991). Two related changes.

## Source-based defaults

Previously every video track fell back to `maintainResolution`, and the
preference was only applied to camera and screen share tracks at all:

```dart
if ([TrackSource.camera, TrackSource.screenShareVideo].contains(track.source)) {
  final degradationPreference = options.degradationPreference ?? DegradationPreference.maintainResolution;
  await track.setDegradationPreference(degradationPreference);
}
```

Now `getDefaultDegradationPreference(source)` resolves camera →
`maintainFramerate` (smoother video for real-time communication), screen
share → `maintainResolution` (clarity matters for text/UI), other →
`balanced`, and it is applied to every video sender. Custom sources
previously got whatever WebRTC derived implicitly from the native
source; `balanced` is the preference the WebRTC spec mandates as the
default and is the honest choice when the application declined to
declare a motion-vs-detail intent.

An explicitly set `degradationPreference` still wins in all cases — the
default only fills a null.

## Backup codec sender

Degradation preference is a property of the **sender**, not of the track
— a top-level field on `RtpParameters`, not per-encoding.
`publishAdditionalCodecForPublication` adds a second transceiver and
therefore a second sender, which was never configured, so the backup
encoder resolved a preference implicitly and could adapt along a
different axis than the primary.

Both senders sink from the same video source, so a diverging backup does
not just degrade itself — its restriction is merged onto the shared
source and affects the primary too.

`setDegradationPreference` now stores the resolved preference and fans
out to every sender, and `publishAdditionalCodecForPublication` applies
it to the backup sender once created. Using the track's stored resolved
value means the two encoders cannot disagree.

Note simulcast is unaffected — all simulcast encodings live under one
sender and already share its preference. Only the backup codec is a
separate sender.

## Tests

`test/options/degradation_preference_test.dart` covers the three source
mappings. Full suite passes (379 tests), `flutter analyze lib/ test/`
clean, `dart format` clean at the repo's 120-column width.

## Cross-SDK

client-sdk-js gets the backup-sender half in livekit/client-sdk-js#2040
(its source-based defaults already matched). The Rust SDK already
resolves the same defaults and has no backup-codec publish path. Swift
follows separately.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants