Skip to content

Apply degradation preference to the backup codec sender - #2040

Open
xianshijing-lk wants to merge 1 commit into
mainfrom
sxian/degradation-preference-backup-codec
Open

Apply degradation preference to the backup codec sender#2040
xianshijing-lk wants to merge 1 commit into
mainfrom
sxian/degradation-preference-backup-codec

Conversation

@xianshijing-lk

@xianshijing-lk xianshijing-lk commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Degradation preference is a property of the sender, not of the track — it is a top-level field on RtpParameters, not per-encoding. A backup codec publishes over its own transceiver and therefore its own sender, so it needs the preference applied separately.

setDegradationPreference only ever configured this.sender (the primary). The backup sender, registered via setSimulcastTrackSender from createSimulcastTransceiverSender, was never touched, so it fell back to the browser's implicit resolution and could adapt along a different axis than the primary encoder.

Concretely, with a VP9/AV1 primary and a VP8 backup:

  • an application-supplied degradationPreference reached the primary encoder only
  • the source-based default from getDefaultDegradationPreference (camera → maintain-framerate, screen share → maintain-resolution, other → balanced) reached the primary encoder only

Changes

  • Extract the sender-level write into applyDegradationPreference(sender).
  • Apply it in setSimulcastTrackSender, so a backup sender gets the preference the primary already resolved to.
  • Make setDegradationPreference fan out to every sender, so a change after the backup is published keeps the two in sync.

Using the track's stored resolved preference (rather than re-deriving in publishAdditionalCodecForTrack) means the two encoders can't disagree.

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

Tests

Three tests in LocalVideoTrack.test.ts covering the primary sender, the backup sender receiving the resolved preference, and a later preference change reaching both. Verified they fail without the fix.

Full suite matches the baseline on main (same 3 pre-existing data-stream failures, +3 passing). eslint, prettier --check and tsc --noEmit clean for the touched files.

Cross-SDK

This is the JS half of aligning the SDKs on the behavior landed in client-sdk-android (livekit/client-sdk-android#991). The Rust SDK already resolves the same source-based defaults and has no backup-codec publish path. Flutter (livekit/client-sdk-flutter#1155) and Swift (livekit/client-sdk-swift#1083) follow separately.

🤖 Generated with Claude Code

Degradation preference is a sender-level property, and a backup codec
publishes over its own sender. Only the primary sender was configured, so
the backup encoder resolved a preference implicitly from the browser and
could adapt along a different axis than the primary.

Apply the resolved preference when a simulcast (backup codec) sender is
registered, and keep every sender in sync when the preference changes
after publish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 91bf9a1

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

This PR includes changesets to release 1 package
Name Type
livekit-client 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 found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +421 to 436
private applyDegradationPreference(sender?: RTCRtpSender) {
if (!sender) {
return;
}
try {
this.log.debug(
`setting degradationPreference to ${this.degradationPreference}`,
this.logContext,
);
const params = sender.getParameters();
params.degradationPreference = this.degradationPreference;
sender.setParameters(params);
} catch (e: any) {
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
}
}

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.

🟡 Failures while applying video degradation settings go unreported and can crash the page with an unhandled error

The video degradation setting is written to each sender without waiting for the write to finish (sender.setParameters(params) at src/room/track/LocalVideoTrack.ts:432), so a failed write escapes the surrounding error handling and surfaces as an unhandled promise rejection instead of a warning.

Impact: On browsers/situations where the write fails (e.g. a backup sender whose connection has already been torn down), the app sees an unhandled rejection and no diagnostic warning is logged.

Why the try/catch never sees setParameters failures, and why the change makes it more likely

RTCRtpSender.setParameters() returns a Promise; it rejects rather than throwing synchronously. The try/catch in applyDegradationPreference (src/room/track/LocalVideoTrack.ts:425-435) therefore only catches synchronous errors from getParameters(), and the rejection from setParameters is never handled.

This was pre-existing for the primary sender, but the PR now fans the same unawaited call out to every backup-codec sender in setDegradationPreference (src/room/track/LocalVideoTrack.ts:410-412) and in setSimulcastTrackSender (src/room/track/LocalVideoTrack.ts:465). Backup senders can be stale/closed — other code in this file explicitly skips them with sc.sender.transport?.state === 'closed' (src/room/track/LocalVideoTrack.ts:333) — so rejections become considerably more likely.

Making applyDegradationPreference async and awaiting setParameters inside the try (and awaiting it from setDegradationPreference) restores the intended warning path.

Suggested change
private applyDegradationPreference(sender?: RTCRtpSender) {
if (!sender) {
return;
}
try {
this.log.debug(
`setting degradationPreference to ${this.degradationPreference}`,
this.logContext,
);
const params = sender.getParameters();
params.degradationPreference = this.degradationPreference;
sender.setParameters(params);
} catch (e: any) {
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
}
}
private async applyDegradationPreference(sender?: RTCRtpSender) {
if (!sender) {
return;
}
try {
this.log.debug(
`setting degradationPreference to ${this.degradationPreference}`,
this.logContext,
);
const params = sender.getParameters();
params.degradationPreference = this.degradationPreference;
await sender.setParameters(params);
} catch (e: any) {
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

This seems like a good suggestion to me, since sender.setParameters is async you probably should await it like the suggested change says, and then also stick an await at the this.applyDegradationPreference(sc.sender); call site above?

Also maybe consider if these can be set in parallel, and if so, then use Promise.all instead of for (const sc of this.simulcastCodecs.values()) {.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 105.01 KB (+0.15% 🔺)
dist/livekit-client.umd.js 113.97 KB (-0.01% 🔽)

Comment on lines +421 to 436
private applyDegradationPreference(sender?: RTCRtpSender) {
if (!sender) {
return;
}
try {
this.log.debug(
`setting degradationPreference to ${this.degradationPreference}`,
this.logContext,
);
const params = sender.getParameters();
params.degradationPreference = this.degradationPreference;
sender.setParameters(params);
} catch (e: any) {
this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext });
}
}

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.

This seems like a good suggestion to me, since sender.setParameters is async you probably should await it like the suggested change says, and then also stick an await at the this.applyDegradationPreference(sc.sender); call site above?

Also maybe consider if these can be set in parallel, and if so, then use Promise.all instead of for (const sc of this.simulcastCodecs.values()) {.

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.

2 participants