@stream-io/video-client 1.22.0 → 1.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/index.browser.es.js +31 -12
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +31 -12
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +31 -12
- package/dist/index.es.js.map +1 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +8 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +10 -6
- package/src/gen/video/sfu/models/models.ts +14 -0
- package/src/rtc/BasePeerConnection.ts +3 -2
- package/src/rtc/Publisher.ts +4 -0
- package/src/rtc/__tests__/Publisher.test.ts +2 -2
- package/src/rtc/__tests__/Subscriber.test.ts +2 -2
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -1
- package/src/rtc/__tests__/videoLayers.test.ts +44 -0
- package/src/rtc/videoLayers.ts +6 -3
- package/src/stats/SfuStatsReporter.ts +6 -0
|
@@ -285,6 +285,14 @@ export interface PublishOption {
|
|
|
285
285
|
* @generated from protobuf field: int32 id = 8;
|
|
286
286
|
*/
|
|
287
287
|
id: number;
|
|
288
|
+
/**
|
|
289
|
+
* If true, instructs the publisher to send only the highest available simulcast layer,
|
|
290
|
+
* disabling all lower layers. This applies to simulcast encodings.
|
|
291
|
+
* For SVC codecs, prefer using the L1T3 (single spatial, 3 temporal layers) mode instead.
|
|
292
|
+
*
|
|
293
|
+
* @generated from protobuf field: bool use_single_layer = 9;
|
|
294
|
+
*/
|
|
295
|
+
useSingleLayer: boolean;
|
|
288
296
|
}
|
|
289
297
|
/**
|
|
290
298
|
* @generated from protobuf message stream.video.sfu.models.Codec
|
package/package.json
CHANGED
package/src/Call.ts
CHANGED
|
@@ -591,6 +591,7 @@ export class Call {
|
|
|
591
591
|
this.statsReporter?.stop();
|
|
592
592
|
this.statsReporter = undefined;
|
|
593
593
|
|
|
594
|
+
this.sfuStatsReporter?.flush();
|
|
594
595
|
this.sfuStatsReporter?.stop();
|
|
595
596
|
this.sfuStatsReporter = undefined;
|
|
596
597
|
|
|
@@ -1013,7 +1014,7 @@ export class Call {
|
|
|
1013
1014
|
);
|
|
1014
1015
|
}
|
|
1015
1016
|
|
|
1016
|
-
if (performingRejoin) {
|
|
1017
|
+
if (performingRejoin && isWsHealthy) {
|
|
1017
1018
|
const strategy = WebsocketReconnectStrategy[this.reconnectStrategy];
|
|
1018
1019
|
await previousSfuClient?.leaveAndClose(
|
|
1019
1020
|
`Closing previous WS after reconnect with strategy: ${strategy}`,
|
|
@@ -1338,16 +1339,12 @@ export class Call {
|
|
|
1338
1339
|
): Promise<void> => {
|
|
1339
1340
|
if (
|
|
1340
1341
|
this.state.callingState === CallingState.RECONNECTING ||
|
|
1342
|
+
this.state.callingState === CallingState.MIGRATING ||
|
|
1341
1343
|
this.state.callingState === CallingState.RECONNECTING_FAILED
|
|
1342
1344
|
)
|
|
1343
1345
|
return;
|
|
1344
1346
|
|
|
1345
1347
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
1346
|
-
this.logger(
|
|
1347
|
-
'info',
|
|
1348
|
-
`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[strategy]}`,
|
|
1349
|
-
);
|
|
1350
|
-
|
|
1351
1348
|
const reconnectStartTime = Date.now();
|
|
1352
1349
|
this.reconnectStrategy = strategy;
|
|
1353
1350
|
this.reconnectReason = reason;
|
|
@@ -1374,6 +1371,12 @@ export class Call {
|
|
|
1374
1371
|
try {
|
|
1375
1372
|
// wait until the network is available
|
|
1376
1373
|
await this.networkAvailableTask?.promise;
|
|
1374
|
+
|
|
1375
|
+
this.logger(
|
|
1376
|
+
'info',
|
|
1377
|
+
`[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[this.reconnectStrategy]}`,
|
|
1378
|
+
);
|
|
1379
|
+
|
|
1377
1380
|
switch (this.reconnectStrategy) {
|
|
1378
1381
|
case WebsocketReconnectStrategy.UNSPECIFIED:
|
|
1379
1382
|
case WebsocketReconnectStrategy.DISCONNECT:
|
|
@@ -1428,6 +1431,7 @@ export class Call {
|
|
|
1428
1431
|
this.state.callingState !== CallingState.RECONNECTING_FAILED &&
|
|
1429
1432
|
this.state.callingState !== CallingState.LEFT
|
|
1430
1433
|
);
|
|
1434
|
+
this.logger('info', '[Reconnect] Reconnection flow finished');
|
|
1431
1435
|
});
|
|
1432
1436
|
};
|
|
1433
1437
|
|
|
@@ -289,6 +289,14 @@ export interface PublishOption {
|
|
|
289
289
|
* @generated from protobuf field: int32 id = 8;
|
|
290
290
|
*/
|
|
291
291
|
id: number;
|
|
292
|
+
/**
|
|
293
|
+
* If true, instructs the publisher to send only the highest available simulcast layer,
|
|
294
|
+
* disabling all lower layers. This applies to simulcast encodings.
|
|
295
|
+
* For SVC codecs, prefer using the L1T3 (single spatial, 3 temporal layers) mode instead.
|
|
296
|
+
*
|
|
297
|
+
* @generated from protobuf field: bool use_single_layer = 9;
|
|
298
|
+
*/
|
|
299
|
+
useSingleLayer: boolean;
|
|
292
300
|
}
|
|
293
301
|
/**
|
|
294
302
|
* @generated from protobuf message stream.video.sfu.models.Codec
|
|
@@ -1314,6 +1322,12 @@ class PublishOption$Type extends MessageType<PublishOption> {
|
|
|
1314
1322
|
T: () => VideoDimension,
|
|
1315
1323
|
},
|
|
1316
1324
|
{ no: 8, name: 'id', kind: 'scalar', T: 5 /*ScalarType.INT32*/ },
|
|
1325
|
+
{
|
|
1326
|
+
no: 9,
|
|
1327
|
+
name: 'use_single_layer',
|
|
1328
|
+
kind: 'scalar',
|
|
1329
|
+
T: 8 /*ScalarType.BOOL*/,
|
|
1330
|
+
},
|
|
1317
1331
|
]);
|
|
1318
1332
|
}
|
|
1319
1333
|
}
|
|
@@ -258,10 +258,11 @@ export abstract class BasePeerConnection {
|
|
|
258
258
|
// do nothing when ICE is restarting
|
|
259
259
|
if (this.isIceRestarting) return;
|
|
260
260
|
|
|
261
|
-
if (state === 'failed'
|
|
261
|
+
if (state === 'failed') {
|
|
262
|
+
this.onUnrecoverableError?.('ICE connection failed');
|
|
263
|
+
} else if (state === 'disconnected') {
|
|
262
264
|
this.logger('debug', `Attempting to restart ICE`);
|
|
263
265
|
this.restartIce().catch((e) => {
|
|
264
|
-
if (this.isDisposed) return;
|
|
265
266
|
const reason = `ICE restart failed`;
|
|
266
267
|
this.logger('error', reason, e);
|
|
267
268
|
this.onUnrecoverableError?.(`${reason}: ${e}`);
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -122,6 +122,10 @@ export class Publisher extends BasePeerConnection {
|
|
|
122
122
|
sendEncodings,
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
const params = transceiver.sender.getParameters();
|
|
126
|
+
params.degradationPreference = 'maintain-framerate';
|
|
127
|
+
await transceiver.sender.setParameters(params);
|
|
128
|
+
|
|
125
129
|
const trackType = publishOption.trackType;
|
|
126
130
|
this.logger('debug', `Added ${TrackType[trackType]} transceiver`);
|
|
127
131
|
this.transceiverCache.add(publishOption, transceiver);
|
|
@@ -243,11 +243,11 @@ describe('Publisher', () => {
|
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
it(`should perform ICE restart when connection state changes to 'failed'`, () => {
|
|
246
|
-
|
|
246
|
+
publisher['onUnrecoverableError'] = vi.fn();
|
|
247
247
|
// @ts-expect-error private api
|
|
248
248
|
publisher['pc'].iceConnectionState = 'failed';
|
|
249
249
|
publisher['onIceConnectionStateChange']();
|
|
250
|
-
expect(publisher
|
|
250
|
+
expect(publisher['onUnrecoverableError']).toHaveBeenCalled();
|
|
251
251
|
});
|
|
252
252
|
|
|
253
253
|
it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
|
|
@@ -103,11 +103,11 @@ describe('Subscriber', () => {
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it(`should perform ICE restart when connection state changes to 'failed'`, () => {
|
|
106
|
-
|
|
106
|
+
subscriber['onUnrecoverableError'] = vi.fn();
|
|
107
107
|
// @ts-expect-error - private field
|
|
108
108
|
subscriber['pc'].iceConnectionState = 'failed';
|
|
109
109
|
subscriber['onIceConnectionStateChange']();
|
|
110
|
-
expect(subscriber
|
|
110
|
+
expect(subscriber['onUnrecoverableError']).toHaveBeenCalled();
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
|
|
@@ -55,7 +55,7 @@ const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
|
|
|
55
55
|
sender: {
|
|
56
56
|
track: null,
|
|
57
57
|
replaceTrack: vi.fn(),
|
|
58
|
-
getParameters: vi.fn(),
|
|
58
|
+
getParameters: vi.fn().mockReturnValue({}),
|
|
59
59
|
setParameters: vi.fn(),
|
|
60
60
|
},
|
|
61
61
|
setCodecPreferences: vi.fn(),
|
|
@@ -170,6 +170,50 @@ describe('videoLayers', () => {
|
|
|
170
170
|
expect(layers[2].rid).toBe('f');
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
it('should activate only a single layer when useSingleLayer is true', () => {
|
|
174
|
+
const track = new MediaStreamTrack();
|
|
175
|
+
// @ts-expect-error - incomplete data
|
|
176
|
+
const layers = computeVideoLayers(track, { useSingleLayer: true });
|
|
177
|
+
expect(layers.length).toBe(3);
|
|
178
|
+
expect(layers[0].active).toBe(false);
|
|
179
|
+
expect(layers[0].rid).toBe('q');
|
|
180
|
+
expect(layers[1].active).toBe(false);
|
|
181
|
+
expect(layers[1].rid).toBe('h');
|
|
182
|
+
expect(layers[2].active).toBe(true);
|
|
183
|
+
expect(layers[2].rid).toBe('f');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should activate only a single layer when useSingleLayer is true in single layer mode', () => {
|
|
187
|
+
const track = new MediaStreamTrack();
|
|
188
|
+
vi.spyOn(track, 'getSettings').mockReturnValue({ width: 320, height: 180 });
|
|
189
|
+
// @ts-expect-error - incomplete data
|
|
190
|
+
const layers = computeVideoLayers(track, { useSingleLayer: true });
|
|
191
|
+
expect(layers.length).toBe(1);
|
|
192
|
+
expect(layers[0].active).toBe(true);
|
|
193
|
+
expect(layers[0].rid).toBe('q');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should activate only one temporal layer when useSingleLayer is true for SVC', () => {
|
|
197
|
+
const track = new MediaStreamTrack();
|
|
198
|
+
const layers = computeVideoLayers(track, {
|
|
199
|
+
// @ts-expect-error - incomplete data
|
|
200
|
+
codec: { name: 'vp9' },
|
|
201
|
+
maxSpatialLayers: 3,
|
|
202
|
+
maxTemporalLayers: 3,
|
|
203
|
+
useSingleLayer: true,
|
|
204
|
+
});
|
|
205
|
+
expect(layers.length).toBe(3);
|
|
206
|
+
expect(layers[0].rid).toBe('q');
|
|
207
|
+
expect(layers[0].active).toBe(false);
|
|
208
|
+
expect(layers[0].scalabilityMode).toBe('L1T3');
|
|
209
|
+
expect(layers[1].active).toBe(false);
|
|
210
|
+
expect(layers[1].rid).toBe('h');
|
|
211
|
+
expect(layers[1].scalabilityMode).toBe('L1T3');
|
|
212
|
+
expect(layers[2].active).toBe(true);
|
|
213
|
+
expect(layers[2].rid).toBe('f');
|
|
214
|
+
expect(layers[2].scalabilityMode).toBe('L1T3');
|
|
215
|
+
});
|
|
216
|
+
|
|
173
217
|
it('should map rids to VideoQuality', () => {
|
|
174
218
|
expect(ridToVideoQuality('q')).toBe(VideoQuality.LOW_UNSPECIFIED);
|
|
175
219
|
expect(ridToVideoQuality('h')).toBe(VideoQuality.MID);
|
package/src/rtc/videoLayers.ts
CHANGED
|
@@ -93,6 +93,7 @@ export const computeVideoLayers = (
|
|
|
93
93
|
maxSpatialLayers = 3,
|
|
94
94
|
maxTemporalLayers = 3,
|
|
95
95
|
videoDimension = { width: 1280, height: 720 },
|
|
96
|
+
useSingleLayer,
|
|
96
97
|
} = publishOption;
|
|
97
98
|
const maxBitrate = getComputedMaxBitrate(
|
|
98
99
|
videoDimension,
|
|
@@ -117,7 +118,7 @@ export const computeVideoLayers = (
|
|
|
117
118
|
// for SVC codecs, we need to set the scalability mode, and the
|
|
118
119
|
// codec will handle the rest (layers, temporal layers, etc.)
|
|
119
120
|
layer.scalabilityMode = toScalabilityMode(
|
|
120
|
-
maxSpatialLayers,
|
|
121
|
+
useSingleLayer ? 1 : maxSpatialLayers,
|
|
121
122
|
maxTemporalLayers,
|
|
122
123
|
);
|
|
123
124
|
} else {
|
|
@@ -136,7 +137,7 @@ export const computeVideoLayers = (
|
|
|
136
137
|
|
|
137
138
|
// for simplicity, we start with all layers enabled, then this function
|
|
138
139
|
// will clear/reassign the layers that are not needed
|
|
139
|
-
return withSimulcastConstraints(settings, optimalVideoLayers);
|
|
140
|
+
return withSimulcastConstraints(settings, optimalVideoLayers, useSingleLayer);
|
|
140
141
|
};
|
|
141
142
|
|
|
142
143
|
/**
|
|
@@ -180,6 +181,7 @@ export const getComputedMaxBitrate = (
|
|
|
180
181
|
const withSimulcastConstraints = (
|
|
181
182
|
settings: MediaTrackSettings,
|
|
182
183
|
optimalVideoLayers: OptimalVideoLayer[],
|
|
184
|
+
useSingleLayer: boolean,
|
|
183
185
|
) => {
|
|
184
186
|
let layers: OptimalVideoLayer[];
|
|
185
187
|
|
|
@@ -196,8 +198,9 @@ const withSimulcastConstraints = (
|
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
const ridMapping = ['q', 'h', 'f'];
|
|
199
|
-
return layers.map<OptimalVideoLayer>((layer, index) => ({
|
|
201
|
+
return layers.map<OptimalVideoLayer>((layer, index, arr) => ({
|
|
200
202
|
...layer,
|
|
201
203
|
rid: ridMapping[index], // reassign rid
|
|
204
|
+
active: useSingleLayer && index < arr.length - 1 ? false : layer.active,
|
|
202
205
|
}));
|
|
203
206
|
};
|
|
@@ -235,6 +235,12 @@ export class SfuStatsReporter {
|
|
|
235
235
|
this.timeoutId = undefined;
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
+
flush = () => {
|
|
239
|
+
this.run().catch((err) => {
|
|
240
|
+
this.logger('warn', 'Failed to flush report stats', err);
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
238
244
|
scheduleOne = (timeout: number) => {
|
|
239
245
|
clearTimeout(this.timeoutId);
|
|
240
246
|
this.timeoutId = setTimeout(() => {
|