Skip to content

Commit 85b34a3

Browse files
authored
feat(client): honor SFU degradationPreference on the publisher (#2241)
### 💡 Overview Picks up `DegradationPreference` from protocol [PR #1886](GetStream/protocol#1886) and wires it through the `Publisher` so the SFU can drive the WebRTC degradation preference instead of it being hard-coded. 🎫 Ticket: https://linear.app/stream/issue/XYZ-123
1 parent d3f017b commit 85b34a3

6 files changed

Lines changed: 241 additions & 11 deletions

File tree

packages/client/src/gen/video/sfu/event/events.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ClientDetails,
1111
Codec,
1212
ConnectionQuality,
13+
DegradationPreference,
1314
Error as Error$,
1415
GoAwayReason,
1516
ICETrickle as ICETrickle$,
@@ -836,6 +837,10 @@ export interface VideoSender {
836837
* @generated from protobuf field: int32 publish_option_id = 5;
837838
*/
838839
publishOptionId: number;
840+
/**
841+
* @generated from protobuf field: stream.video.sfu.models.DegradationPreference degradation_preference = 6;
842+
*/
843+
degradationPreference: DegradationPreference;
839844
}
840845
/**
841846
* sent to users when they need to change the quality of their video
@@ -1796,6 +1801,16 @@ class VideoSender$Type extends MessageType<VideoSender> {
17961801
kind: 'scalar',
17971802
T: 5 /*ScalarType.INT32*/,
17981803
},
1804+
{
1805+
no: 6,
1806+
name: 'degradation_preference',
1807+
kind: 'enum',
1808+
T: () => [
1809+
'stream.video.sfu.models.DegradationPreference',
1810+
DegradationPreference,
1811+
'DEGRADATION_PREFERENCE_',
1812+
],
1813+
},
17991814
]);
18001815
}
18011816
}

packages/client/src/gen/video/sfu/models/models.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,12 @@ export interface PublishOption {
307307
* @generated from protobuf field: repeated stream.video.sfu.models.AudioBitrate audio_bitrate_profiles = 10;
308308
*/
309309
audioBitrateProfiles: AudioBitrate[];
310+
/**
311+
* The degradation preference for video encoding.
312+
*
313+
* @generated from protobuf field: stream.video.sfu.models.DegradationPreference degradation_preference = 11;
314+
*/
315+
degradationPreference: DegradationPreference;
310316
}
311317
/**
312318
* @generated from protobuf message stream.video.sfu.models.Codec
@@ -1172,6 +1178,34 @@ export enum ClientCapability {
11721178
*/
11731179
SUBSCRIBER_VIDEO_PAUSE = 1,
11741180
}
1181+
/**
1182+
* DegradationPreference represents the RTCDegradationPreference from WebRTC.
1183+
* See https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpSender/setParameters#degradationpreference
1184+
*
1185+
* @generated from protobuf enum stream.video.sfu.models.DegradationPreference
1186+
*/
1187+
export enum DegradationPreference {
1188+
/**
1189+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_UNSPECIFIED = 0;
1190+
*/
1191+
UNSPECIFIED = 0,
1192+
/**
1193+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_BALANCED = 1;
1194+
*/
1195+
BALANCED = 1,
1196+
/**
1197+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE = 2;
1198+
*/
1199+
MAINTAIN_FRAMERATE = 2,
1200+
/**
1201+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_RESOLUTION = 3;
1202+
*/
1203+
MAINTAIN_RESOLUTION = 3,
1204+
/**
1205+
* @generated from protobuf enum value: DEGRADATION_PREFERENCE_MAINTAIN_FRAMERATE_AND_RESOLUTION = 4;
1206+
*/
1207+
MAINTAIN_FRAMERATE_AND_RESOLUTION = 4,
1208+
}
11751209
// @generated message type with reflection information, may provide speed optimized methods
11761210
class CallState$Type extends MessageType<CallState> {
11771211
constructor() {
@@ -1441,6 +1475,16 @@ class PublishOption$Type extends MessageType<PublishOption> {
14411475
repeat: 2 /*RepeatType.UNPACKED*/,
14421476
T: () => AudioBitrate,
14431477
},
1478+
{
1479+
no: 11,
1480+
name: 'degradation_preference',
1481+
kind: 'enum',
1482+
T: () => [
1483+
'stream.video.sfu.models.DegradationPreference',
1484+
DegradationPreference,
1485+
'DEGRADATION_PREFERENCE_',
1486+
],
1487+
},
14441488
]);
14451489
}
14461490
}

packages/client/src/rtc/Publisher.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
toVideoLayers,
2121
} from './layers';
2222
import { isSvcCodec } from './codecs';
23+
import { toRTCDegradationPreference } from './helpers/degradationPreference';
2324
import { isAudioTrackType } from './helpers/tracks';
2425
import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
2526
import { withoutConcurrency } from '../helpers/concurrency';
@@ -135,7 +136,9 @@ export class Publisher extends BasePeerConnection {
135136
});
136137

137138
const params = transceiver.sender.getParameters();
138-
params.degradationPreference = 'maintain-framerate';
139+
params.degradationPreference =
140+
toRTCDegradationPreference(publishOption.degradationPreference) ??
141+
'maintain-framerate';
139142
await transceiver.sender.setParameters(params);
140143

141144
const trackType = publishOption.trackType;
@@ -344,6 +347,17 @@ export class Publisher extends BasePeerConnection {
344347
}
345348
}
346349

350+
const degradationPreference = toRTCDegradationPreference(
351+
videoSender.degradationPreference,
352+
);
353+
if (
354+
degradationPreference &&
355+
params.degradationPreference !== degradationPreference
356+
) {
357+
params.degradationPreference = degradationPreference;
358+
changed = true;
359+
}
360+
347361
const activeEncoders = params.encodings.filter((e) => e.active);
348362
if (!changed) {
349363
return this.logger.info(`${tag} no change:`, activeEncoders);

packages/client/src/rtc/__tests__/Publisher.test.ts

Lines changed: 122 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import './mocks/webrtc.mocks';
22

33
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
44
import { anyString } from 'vitest-mock-extended';
5+
import { fromPartial } from '@total-typescript/shoehorn';
56
import { NegotiationError } from '../NegotiationError';
67
import { Publisher } from '../Publisher';
78
import { ReconnectReason } from '../types';
89
import { CallState } from '../../store';
910
import { StreamSfuClient } from '../../StreamSfuClient';
1011
import { DispatchableMessage, Dispatcher } from '../Dispatcher';
1112
import {
13+
DegradationPreference,
1214
ErrorCode,
1315
PeerType,
1416
PublishOption,
@@ -82,6 +84,7 @@ describe('Publisher', () => {
8284
fps: 30,
8385
maxTemporalLayers: 3,
8486
maxSpatialLayers: 3,
87+
degradationPreference: DegradationPreference.UNSPECIFIED,
8588
},
8689
],
8790
);
@@ -168,16 +171,19 @@ describe('Publisher', () => {
168171
changePublishQuality: {
169172
audioSenders: [],
170173
videoSenders: [
171-
{
174+
fromPartial({
172175
publishOptionId: 1,
173176
trackType: TrackType.VIDEO,
174177
layers: [],
175-
},
176-
{
178+
degradationPreference: DegradationPreference.BALANCED,
179+
}),
180+
fromPartial({
177181
publishOptionId: 2,
178182
trackType: TrackType.SCREEN_SHARE,
179183
layers: [],
180-
},
184+
degradationPreference:
185+
DegradationPreference.MAINTAIN_RESOLUTION,
186+
}),
181187
],
182188
},
183189
},
@@ -657,6 +663,7 @@ describe('Publisher', () => {
657663
await publisher['changePublishQuality']({
658664
publishOptionId: 1,
659665
trackType: TrackType.VIDEO,
666+
degradationPreference: DegradationPreference.UNSPECIFIED,
660667
layers: [
661668
{
662669
name: 'q',
@@ -733,6 +740,7 @@ describe('Publisher', () => {
733740
await publisher['changePublishQuality']({
734741
publishOptionId: 1,
735742
trackType: TrackType.VIDEO,
743+
degradationPreference: DegradationPreference.UNSPECIFIED,
736744
layers: [
737745
{
738746
name: 'q',
@@ -796,6 +804,7 @@ describe('Publisher', () => {
796804
await publisher['changePublishQuality']({
797805
publishOptionId: 1,
798806
trackType: TrackType.VIDEO,
807+
degradationPreference: DegradationPreference.UNSPECIFIED,
799808
layers: [
800809
{
801810
name: 'q',
@@ -855,6 +864,7 @@ describe('Publisher', () => {
855864
await publisher['changePublishQuality']({
856865
publishOptionId: 1,
857866
trackType: TrackType.VIDEO,
867+
degradationPreference: DegradationPreference.UNSPECIFIED,
858868
layers: [
859869
{
860870
name: 'q',
@@ -879,6 +889,93 @@ describe('Publisher', () => {
879889
},
880890
]);
881891
});
892+
893+
it('applies degradationPreference from the SFU event', async () => {
894+
const transceiver = new RTCRtpTransceiver();
895+
const setParametersSpy = vi
896+
.spyOn(transceiver.sender, 'setParameters')
897+
.mockResolvedValue();
898+
vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
899+
// @ts-expect-error incomplete data
900+
codecs: [{ mimeType: 'video/VP8' }],
901+
encodings: [{ rid: 'q', active: true }],
902+
degradationPreference: 'maintain-framerate',
903+
});
904+
905+
publisher['transceiverCache'].add({
906+
// @ts-expect-error incomplete data
907+
publishOption: { trackType: TrackType.VIDEO, id: 1 },
908+
transceiver,
909+
options: {},
910+
});
911+
912+
await publisher['changePublishQuality']({
913+
publishOptionId: 1,
914+
trackType: TrackType.VIDEO,
915+
degradationPreference: DegradationPreference.BALANCED,
916+
layers: [
917+
{
918+
name: 'q',
919+
active: true,
920+
maxBitrate: 100,
921+
scaleResolutionDownBy: 1,
922+
maxFramerate: 30,
923+
scalabilityMode: '',
924+
},
925+
],
926+
});
927+
928+
expect(setParametersSpy).toHaveBeenCalled();
929+
expect(setParametersSpy.mock.calls[0][0].degradationPreference).toBe(
930+
'balanced',
931+
);
932+
});
933+
934+
it('does not call setParameters when nothing changes and degradationPreference is UNSPECIFIED', async () => {
935+
const transceiver = new RTCRtpTransceiver();
936+
const setParametersSpy = vi
937+
.spyOn(transceiver.sender, 'setParameters')
938+
.mockResolvedValue();
939+
vi.spyOn(transceiver.sender, 'getParameters').mockReturnValue({
940+
// @ts-expect-error incomplete data
941+
codecs: [{ mimeType: 'video/VP8' }],
942+
encodings: [
943+
{
944+
rid: 'q',
945+
active: true,
946+
maxBitrate: 100,
947+
scaleResolutionDownBy: 1,
948+
maxFramerate: 30,
949+
},
950+
],
951+
degradationPreference: 'maintain-framerate',
952+
});
953+
954+
publisher['transceiverCache'].add({
955+
// @ts-expect-error incomplete data
956+
publishOption: { trackType: TrackType.VIDEO, id: 1 },
957+
transceiver,
958+
options: {},
959+
});
960+
961+
await publisher['changePublishQuality']({
962+
publishOptionId: 1,
963+
trackType: TrackType.VIDEO,
964+
degradationPreference: DegradationPreference.UNSPECIFIED,
965+
layers: [
966+
{
967+
name: 'q',
968+
active: true,
969+
maxBitrate: 100,
970+
scaleResolutionDownBy: 1,
971+
maxFramerate: 30,
972+
scalabilityMode: '',
973+
},
974+
],
975+
});
976+
977+
expect(setParametersSpy).not.toHaveBeenCalled();
978+
});
882979
});
883980

884981
describe('changePublishOptions', () => {
@@ -893,12 +990,27 @@ describe('Publisher', () => {
893990
vi.spyOn(publisher, 'negotiate').mockResolvedValue();
894991

895992
publisher['publishOptions'] = [
896-
// @ts-expect-error incomplete data
897-
{ trackType: TrackType.VIDEO, id: 0, codec: { name: 'vp8' } },
898-
// @ts-expect-error incomplete data
899-
{ trackType: TrackType.VIDEO, id: 1, codec: { name: 'av1' } },
900-
// @ts-expect-error incomplete data
901-
{ trackType: TrackType.VIDEO, id: 2, codec: { name: 'vp9' } },
993+
{
994+
trackType: TrackType.VIDEO,
995+
id: 0,
996+
// @ts-expect-error incomplete data
997+
codec: { name: 'vp8' },
998+
degradationPreference: DegradationPreference.UNSPECIFIED,
999+
},
1000+
{
1001+
trackType: TrackType.VIDEO,
1002+
id: 1,
1003+
// @ts-expect-error incomplete data
1004+
codec: { name: 'av1' },
1005+
degradationPreference: DegradationPreference.UNSPECIFIED,
1006+
},
1007+
{
1008+
trackType: TrackType.VIDEO,
1009+
id: 2,
1010+
// @ts-expect-error incomplete data
1011+
codec: { name: 'vp9' },
1012+
degradationPreference: DegradationPreference.UNSPECIFIED,
1013+
},
9021014
];
9031015

9041016
publisher['transceiverCache'].add({
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { DegradationPreference } from '../../../gen/video/sfu/models/models';
3+
import { toRTCDegradationPreference } from '../degradationPreference';
4+
5+
describe('toRTCDegradationPreference', () => {
6+
it.each([
7+
[DegradationPreference.BALANCED, 'balanced'],
8+
[DegradationPreference.MAINTAIN_FRAMERATE, 'maintain-framerate'],
9+
[DegradationPreference.MAINTAIN_RESOLUTION, 'maintain-resolution'],
10+
[
11+
DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION,
12+
'maintain-framerate-and-resolution',
13+
],
14+
])('maps %s to "%s"', (preference, expected) => {
15+
expect(toRTCDegradationPreference(preference)).toBe(expected);
16+
});
17+
18+
it('returns undefined for UNSPECIFIED', () => {
19+
expect(
20+
toRTCDegradationPreference(DegradationPreference.UNSPECIFIED),
21+
).toBeUndefined();
22+
});
23+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { DegradationPreference } from '../../gen/video/sfu/models/models';
2+
import { ensureExhausted } from '../../helpers/ensureExhausted';
3+
4+
export const toRTCDegradationPreference = (
5+
preference: DegradationPreference,
6+
): RTCDegradationPreference | undefined => {
7+
switch (preference) {
8+
case DegradationPreference.BALANCED:
9+
return 'balanced';
10+
case DegradationPreference.MAINTAIN_FRAMERATE:
11+
return 'maintain-framerate';
12+
case DegradationPreference.MAINTAIN_RESOLUTION:
13+
return 'maintain-resolution';
14+
case DegradationPreference.MAINTAIN_FRAMERATE_AND_RESOLUTION:
15+
// @ts-expect-error not in the typedefs yet
16+
return 'maintain-framerate-and-resolution';
17+
case DegradationPreference.UNSPECIFIED:
18+
return undefined;
19+
default:
20+
ensureExhausted(preference, 'Unknown degradation preference');
21+
}
22+
};

0 commit comments

Comments
 (0)