From 91bf9a1648f3cae25195bb59e06815697f95e23e Mon Sep 17 00:00:00 2001 From: shijing xian Date: Fri, 7 Aug 2026 15:24:30 +0800 Subject: [PATCH 1/2] Apply degradation preference to the backup codec sender 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) --- .changeset/olive-pears-repeat.md | 7 +++ src/room/track/LocalVideoTrack.test.ts | 84 +++++++++++++++++++++++++- src/room/track/LocalVideoTrack.ts | 39 +++++++++--- 3 files changed, 119 insertions(+), 11 deletions(-) create mode 100644 .changeset/olive-pears-repeat.md diff --git a/.changeset/olive-pears-repeat.md b/.changeset/olive-pears-repeat.md new file mode 100644 index 0000000000..f0c594b2d5 --- /dev/null +++ b/.changeset/olive-pears-repeat.md @@ -0,0 +1,7 @@ +--- +'livekit-client': patch +--- + +Apply the resolved degradation preference to the backup codec's sender + +Degradation preference is a property of the sender, not of the track, and a backup codec publishes over its own sender. Previously only the primary sender was configured, so the backup encoder resolved a preference implicitly and could adapt along a different axis than the primary. diff --git a/src/room/track/LocalVideoTrack.test.ts b/src/room/track/LocalVideoTrack.test.ts index 4b7df6f948..993bde1bc4 100644 --- a/src/room/track/LocalVideoTrack.test.ts +++ b/src/room/track/LocalVideoTrack.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest'; -import { videoLayersFromEncodings } from './LocalVideoTrack'; +import { describe, expect, it, vi } from 'vitest'; +import LocalVideoTrack, { videoLayersFromEncodings } from './LocalVideoTrack'; +import type { SimulcastTrackInfo } from './LocalVideoTrack'; import { VideoQuality } from './Track'; +import type { VideoCodec } from './options'; describe('videoLayersFromEncodings', () => { it('returns single layer for no encoding', () => { @@ -131,3 +133,81 @@ describe('videoLayersFromEncodings', () => { expect(layers[2].width).toBe(720); }); }); + +function makeSender() { + let params: RTCRtpSendParameters = { + encodings: [], + transactionId: '', + codecs: [], + headerExtensions: [], + rtcp: {}, + }; + return { + getParameters: () => params, + setParameters: vi.fn((next: RTCRtpSendParameters) => { + params = next; + return Promise.resolve(); + }), + get degradationPreference() { + return params.degradationPreference; + }, + }; +} + +function makeTrack() { + const track = Object.create(LocalVideoTrack.prototype) as LocalVideoTrack; + Object.assign(track, { + log: { debug: vi.fn(), warn: vi.fn() }, + simulcastCodecs: new Map(), + subscribedCodecs: undefined, + }); + // logContext and mediaStreamTrack are getters we don't set up state for here + Object.defineProperty(track, 'logContext', { get: () => ({}) }); + Object.defineProperty(track, 'mediaStreamTrack', { get: () => ({ clone: () => ({}) }) }); + return track; +} + +describe('setDegradationPreference', () => { + it('applies the preference to the primary sender', async () => { + const track = makeTrack(); + const sender = makeSender(); + Object.assign(track, { _sender: sender }); + + await track.setDegradationPreference('maintain-resolution'); + + expect(sender.degradationPreference).toBe('maintain-resolution'); + }); + + it('applies the resolved preference to a backup codec sender', async () => { + const track = makeTrack(); + const primary = makeSender(); + Object.assign(track, { _sender: primary }); + + await track.setDegradationPreference('maintain-resolution'); + + // the backup codec transceiver is created later, when the server asks for it + const backupInfo = track.addSimulcastTrack('vp8', [])!; + const backup = makeSender(); + track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender); + + expect(backupInfo.sender).toBe(backup); + expect(backup.degradationPreference).toBe('maintain-resolution'); + expect(primary.degradationPreference).toBe('maintain-resolution'); + }); + + it('updates every sender when the preference changes after the backup is published', async () => { + const track = makeTrack(); + const primary = makeSender(); + Object.assign(track, { _sender: primary }); + await track.setDegradationPreference('maintain-framerate'); + + track.addSimulcastTrack('vp8', []); + const backup = makeSender(); + track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender); + + await track.setDegradationPreference('balanced'); + + expect(primary.degradationPreference).toBe('balanced'); + expect(backup.degradationPreference).toBe('balanced'); + }); +}); diff --git a/src/room/track/LocalVideoTrack.ts b/src/room/track/LocalVideoTrack.ts index 34f5daba10..7650ab4da6 100644 --- a/src/room/track/LocalVideoTrack.ts +++ b/src/room/track/LocalVideoTrack.ts @@ -406,15 +406,32 @@ export default class LocalVideoTrack extends LocalTrack { async setDegradationPreference(preference: RTCDegradationPreference) { this.degradationPreference = preference; - if (this.sender) { - try { - this.log.debug(`setting degradationPreference to ${preference}`, this.logContext); - const params = this.sender.getParameters(); - params.degradationPreference = preference; - this.sender.setParameters(params); - } catch (e: any) { - this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext }); - } + this.applyDegradationPreference(this.sender); + for (const sc of this.simulcastCodecs.values()) { + this.applyDegradationPreference(sc.sender); + } + } + + /** + * Degradation preference is a property of the sender, not of the track, so every sender + * publishing this track needs it applied separately. A backup codec publishes over its + * own sender, which would otherwise let the browser resolve a preference implicitly and + * diverge from the primary encoder. + */ + 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 }); } } @@ -443,6 +460,10 @@ export default class LocalVideoTrack extends LocalTrack { } simulcastCodecInfo.sender = sender; + // the backup codec publishes over its own sender, so it needs the same degradation + // preference the primary sender resolved to. + this.applyDegradationPreference(sender); + // browser will reenable disabled codec/layers after new codec has been published, // so refresh subscribedCodecs after publish a new codec setTimeout(() => { From 4560812e98b565579685be4b6c98c0a9b3187297 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Mon, 10 Aug 2026 17:15:23 +0800 Subject: [PATCH 2/2] Await setParameters when applying degradation preference setParameters returns a promise; not awaiting it meant failures were invisible and the write could still be in flight when the caller returned. Await it, and await applyDegradationPreference at both call sites, which makes setSimulcastTrackSender async. Keep the senders sequential rather than Promise.all: setParameters is only valid against the parameters most recently returned by getParameters, which is the same hazard senderLock exists for in this file. There are at most a couple of senders, so there is nothing to gain from overlapping them. The test mock now resolves setParameters on a later task, like the real one, so a missing await is observable. Co-Authored-By: Claude Opus 5 (1M context) --- src/room/RTCEngine.ts | 2 +- src/room/track/LocalVideoTrack.test.ts | 33 ++++++++++++++++++++++---- src/room/track/LocalVideoTrack.ts | 17 ++++++++----- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index ab0a7bb4ae..dd8a514d99 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1095,7 +1095,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!opts.videoCodec) { return; } - track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender); + await track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender); return transceiver.sender; } diff --git a/src/room/track/LocalVideoTrack.test.ts b/src/room/track/LocalVideoTrack.test.ts index 993bde1bc4..03c2e63afb 100644 --- a/src/room/track/LocalVideoTrack.test.ts +++ b/src/room/track/LocalVideoTrack.test.ts @@ -134,7 +134,7 @@ describe('videoLayersFromEncodings', () => { }); }); -function makeSender() { +function makeSender(label = 'sender', events: string[] = []) { let params: RTCRtpSendParameters = { encodings: [], transactionId: '', @@ -144,9 +144,13 @@ function makeSender() { }; return { getParameters: () => params, - setParameters: vi.fn((next: RTCRtpSendParameters) => { + // resolves on a later task, like the real setParameters, so that a caller + // which fails to await it never observes the update + setParameters: vi.fn(async (next: RTCRtpSendParameters) => { + events.push(`${label}:start`); + await new Promise((resolve) => setTimeout(resolve, 0)); params = next; - return Promise.resolve(); + events.push(`${label}:done`); }), get degradationPreference() { return params.degradationPreference; @@ -188,7 +192,7 @@ describe('setDegradationPreference', () => { // the backup codec transceiver is created later, when the server asks for it const backupInfo = track.addSimulcastTrack('vp8', [])!; const backup = makeSender(); - track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender); + await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender); expect(backupInfo.sender).toBe(backup); expect(backup.degradationPreference).toBe('maintain-resolution'); @@ -203,11 +207,30 @@ describe('setDegradationPreference', () => { track.addSimulcastTrack('vp8', []); const backup = makeSender(); - track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender); + await track.setSimulcastTrackSender('vp8', backup as unknown as RTCRtpSender); await track.setDegradationPreference('balanced'); expect(primary.degradationPreference).toBe('balanced'); expect(backup.degradationPreference).toBe('balanced'); }); + + it('applies to senders one at a time', async () => { + const events: string[] = []; + const track = makeTrack(); + const primary = makeSender('primary', events); + Object.assign(track, { _sender: primary }); + track.addSimulcastTrack('vp8', []); + await track.setSimulcastTrackSender( + 'vp8', + makeSender('backup', events) as unknown as RTCRtpSender, + ); + events.length = 0; + + await track.setDegradationPreference('balanced'); + + // setParameters is only valid against the most recent getParameters, so the + // writes must not overlap + expect(events).toEqual(['primary:start', 'primary:done', 'backup:start', 'backup:done']); + }); }); diff --git a/src/room/track/LocalVideoTrack.ts b/src/room/track/LocalVideoTrack.ts index 7650ab4da6..5c99fc9b0c 100644 --- a/src/room/track/LocalVideoTrack.ts +++ b/src/room/track/LocalVideoTrack.ts @@ -406,9 +406,10 @@ export default class LocalVideoTrack extends LocalTrack { async setDegradationPreference(preference: RTCDegradationPreference) { this.degradationPreference = preference; - this.applyDegradationPreference(this.sender); + // applied one sender at a time on purpose, see applyDegradationPreference + await this.applyDegradationPreference(this.sender); for (const sc of this.simulcastCodecs.values()) { - this.applyDegradationPreference(sc.sender); + await this.applyDegradationPreference(sc.sender); } } @@ -417,8 +418,12 @@ export default class LocalVideoTrack extends LocalTrack { * publishing this track needs it applied separately. A backup codec publishes over its * own sender, which would otherwise let the browser resolve a preference implicitly and * diverge from the primary encoder. + * + * Callers apply this sequentially rather than concurrently: `setParameters` is only valid + * against the parameters most recently returned by `getParameters`, which is why this file + * serializes other sender parameter updates through `senderLock`. */ - private applyDegradationPreference(sender?: RTCRtpSender) { + private async applyDegradationPreference(sender?: RTCRtpSender) { if (!sender) { return; } @@ -429,7 +434,7 @@ export default class LocalVideoTrack extends LocalTrack { ); const params = sender.getParameters(); params.degradationPreference = this.degradationPreference; - sender.setParameters(params); + await sender.setParameters(params); } catch (e: any) { this.log.warn(`failed to set degradationPreference`, { error: e, ...this.logContext }); } @@ -453,7 +458,7 @@ export default class LocalVideoTrack extends LocalTrack { return simulcastCodecInfo; } - setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) { + async setSimulcastTrackSender(codec: VideoCodec, sender: RTCRtpSender) { const simulcastCodecInfo = this.simulcastCodecs.get(codec); if (!simulcastCodecInfo) { return; @@ -462,7 +467,7 @@ export default class LocalVideoTrack extends LocalTrack { // the backup codec publishes over its own sender, so it needs the same degradation // preference the primary sender resolved to. - this.applyDegradationPreference(sender); + await this.applyDegradationPreference(sender); // browser will reenable disabled codec/layers after new codec has been published, // so refresh subscribedCodecs after publish a new codec