@stream-io/video-client 1.8.4 → 1.9.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 +14 -0
- package/dist/index.browser.es.js +409 -441
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +409 -441
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +409 -441
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +10 -12
- package/dist/src/devices/CameraManager.d.ts +2 -22
- package/dist/src/events/internal.d.ts +0 -4
- package/dist/src/gen/video/sfu/event/events.d.ts +2 -71
- package/dist/src/helpers/sdp-munging.d.ts +8 -0
- package/dist/src/rtc/Publisher.d.ts +18 -23
- package/dist/src/rtc/bitrateLookup.d.ts +2 -0
- package/dist/src/rtc/codecs.d.ts +9 -2
- package/dist/src/rtc/videoLayers.d.ts +31 -4
- package/dist/src/types.d.ts +30 -2
- package/package.json +1 -1
- package/src/Call.ts +21 -38
- package/src/devices/CameraManager.ts +8 -42
- package/src/devices/ScreenShareManager.ts +1 -3
- package/src/devices/__tests__/CameraManager.test.ts +0 -15
- package/src/devices/__tests__/ScreenShareManager.test.ts +0 -14
- package/src/events/callEventHandlers.ts +0 -2
- package/src/events/internal.ts +0 -16
- package/src/gen/video/sfu/event/events.ts +8 -120
- package/src/helpers/sdp-munging.ts +38 -15
- package/src/rtc/Publisher.ts +211 -317
- package/src/rtc/__tests__/Publisher.test.ts +196 -7
- package/src/rtc/__tests__/bitrateLookup.test.ts +12 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +2 -0
- package/src/rtc/__tests__/videoLayers.test.ts +51 -36
- package/src/rtc/bitrateLookup.ts +61 -0
- package/src/rtc/codecs.ts +56 -9
- package/src/rtc/videoLayers.ts +68 -19
- package/src/types.ts +30 -2
|
@@ -17,7 +17,8 @@ vi.mock('../../StreamSfuClient', () => {
|
|
|
17
17
|
};
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
vi.mock('../codecs', () => {
|
|
20
|
+
vi.mock('../codecs', async () => {
|
|
21
|
+
const codecs = await vi.importActual('../codecs');
|
|
21
22
|
return {
|
|
22
23
|
getPreferredCodecs: vi.fn((): RTCRtpCodecCapability[] => [
|
|
23
24
|
{
|
|
@@ -27,6 +28,8 @@ vi.mock('../codecs', () => {
|
|
|
27
28
|
sdpFmtpLine: 'profile-level-id=42e01f',
|
|
28
29
|
},
|
|
29
30
|
]),
|
|
31
|
+
getOptimalVideoCodec: codecs.getOptimalVideoCodec,
|
|
32
|
+
isSvcCodec: codecs.isSvcCodec,
|
|
30
33
|
};
|
|
31
34
|
});
|
|
32
35
|
|
|
@@ -136,11 +139,7 @@ describe('Publisher', () => {
|
|
|
136
139
|
vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(newTrack);
|
|
137
140
|
|
|
138
141
|
expect(track.stop).toHaveBeenCalled();
|
|
139
|
-
expect(
|
|
140
|
-
'ended',
|
|
141
|
-
expect.any(Function),
|
|
142
|
-
);
|
|
143
|
-
expect(newTrack.addEventListener).toHaveBeenCalledWith(
|
|
142
|
+
expect(newTrack.addEventListener).not.toHaveBeenCalledWith(
|
|
144
143
|
'ended',
|
|
145
144
|
expect.any(Function),
|
|
146
145
|
);
|
|
@@ -154,7 +153,7 @@ describe('Publisher', () => {
|
|
|
154
153
|
);
|
|
155
154
|
});
|
|
156
155
|
|
|
157
|
-
it('can publish and un-
|
|
156
|
+
it('can publish and un-publish with just enabling and disabling tracks', async () => {
|
|
158
157
|
const mediaStream = new MediaStream();
|
|
159
158
|
const track = new MediaStreamTrack();
|
|
160
159
|
mediaStream.addTrack(track);
|
|
@@ -277,4 +276,194 @@ describe('Publisher', () => {
|
|
|
277
276
|
expect(publisher.restartIce).toHaveBeenCalled();
|
|
278
277
|
});
|
|
279
278
|
});
|
|
279
|
+
|
|
280
|
+
describe('changePublishQuality', () => {
|
|
281
|
+
it('can dynamically activate/deactivate simulcast layers', async () => {
|
|
282
|
+
const transceiver = new RTCRtpTransceiver();
|
|
283
|
+
const setParametersSpy = vi
|
|
284
|
+
.spyOn(transceiver.sender, 'setParameters')
|
|
285
|
+
.mockResolvedValue();
|
|
286
|
+
const getParametersSpy = vi
|
|
287
|
+
.spyOn(transceiver.sender, 'getParameters')
|
|
288
|
+
.mockReturnValue({
|
|
289
|
+
codecs: [
|
|
290
|
+
// @ts-expect-error incomplete data
|
|
291
|
+
{ mimeType: 'video/VP8' },
|
|
292
|
+
// @ts-expect-error incomplete data
|
|
293
|
+
{ mimeType: 'video/VP9' },
|
|
294
|
+
// @ts-expect-error incomplete data
|
|
295
|
+
{ mimeType: 'video/H264' },
|
|
296
|
+
// @ts-expect-error incomplete data
|
|
297
|
+
{ mimeType: 'video/AV1' },
|
|
298
|
+
],
|
|
299
|
+
encodings: [
|
|
300
|
+
{ rid: 'q', active: true },
|
|
301
|
+
{ rid: 'h', active: true },
|
|
302
|
+
{ rid: 'f', active: true },
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// inject the transceiver
|
|
307
|
+
publisher['transceiverCache'].set(TrackType.VIDEO, transceiver);
|
|
308
|
+
|
|
309
|
+
await publisher['changePublishQuality']([
|
|
310
|
+
{
|
|
311
|
+
name: 'q',
|
|
312
|
+
active: true,
|
|
313
|
+
maxBitrate: 100,
|
|
314
|
+
scaleResolutionDownBy: 4,
|
|
315
|
+
maxFramerate: 30,
|
|
316
|
+
scalabilityMode: '',
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'h',
|
|
320
|
+
active: false,
|
|
321
|
+
maxBitrate: 150,
|
|
322
|
+
scaleResolutionDownBy: 2,
|
|
323
|
+
maxFramerate: 30,
|
|
324
|
+
scalabilityMode: '',
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: 'f',
|
|
328
|
+
active: true,
|
|
329
|
+
maxBitrate: 200,
|
|
330
|
+
scaleResolutionDownBy: 1,
|
|
331
|
+
maxFramerate: 30,
|
|
332
|
+
scalabilityMode: '',
|
|
333
|
+
},
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
expect(getParametersSpy).toHaveBeenCalled();
|
|
337
|
+
expect(setParametersSpy).toHaveBeenCalled();
|
|
338
|
+
expect(setParametersSpy.mock.calls[0][0].encodings).toEqual([
|
|
339
|
+
{
|
|
340
|
+
rid: 'q',
|
|
341
|
+
active: true,
|
|
342
|
+
maxBitrate: 100,
|
|
343
|
+
scaleResolutionDownBy: 4,
|
|
344
|
+
maxFramerate: 30,
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
rid: 'h',
|
|
348
|
+
active: false,
|
|
349
|
+
maxBitrate: 150,
|
|
350
|
+
scaleResolutionDownBy: 2,
|
|
351
|
+
maxFramerate: 30,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
rid: 'f',
|
|
355
|
+
active: true,
|
|
356
|
+
maxBitrate: 200,
|
|
357
|
+
scaleResolutionDownBy: 1,
|
|
358
|
+
maxFramerate: 30,
|
|
359
|
+
},
|
|
360
|
+
]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('can dynamically update scalability mode in SVC', async () => {
|
|
364
|
+
const transceiver = new RTCRtpTransceiver();
|
|
365
|
+
const setParametersSpy = vi
|
|
366
|
+
.spyOn(transceiver.sender, 'setParameters')
|
|
367
|
+
.mockResolvedValue();
|
|
368
|
+
const getParametersSpy = vi
|
|
369
|
+
.spyOn(transceiver.sender, 'getParameters')
|
|
370
|
+
.mockReturnValue({
|
|
371
|
+
codecs: [
|
|
372
|
+
// @ts-expect-error incomplete data
|
|
373
|
+
{ mimeType: 'video/VP9' },
|
|
374
|
+
// @ts-expect-error incomplete data
|
|
375
|
+
{ mimeType: 'video/AV1' },
|
|
376
|
+
// @ts-expect-error incomplete data
|
|
377
|
+
{ mimeType: 'video/VP8' },
|
|
378
|
+
// @ts-expect-error incomplete data
|
|
379
|
+
{ mimeType: 'video/H264' },
|
|
380
|
+
],
|
|
381
|
+
encodings: [
|
|
382
|
+
{
|
|
383
|
+
rid: 'q',
|
|
384
|
+
active: true,
|
|
385
|
+
maxBitrate: 100,
|
|
386
|
+
// @ts-expect-error not in the standard lib yet
|
|
387
|
+
scalabilityMode: 'L3T3_KEY',
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// inject the transceiver
|
|
393
|
+
publisher['transceiverCache'].set(TrackType.VIDEO, transceiver);
|
|
394
|
+
|
|
395
|
+
await publisher['changePublishQuality']([
|
|
396
|
+
{
|
|
397
|
+
name: 'q',
|
|
398
|
+
active: true,
|
|
399
|
+
maxBitrate: 50,
|
|
400
|
+
scaleResolutionDownBy: 1,
|
|
401
|
+
maxFramerate: 30,
|
|
402
|
+
scalabilityMode: 'L1T3',
|
|
403
|
+
},
|
|
404
|
+
]);
|
|
405
|
+
|
|
406
|
+
expect(getParametersSpy).toHaveBeenCalled();
|
|
407
|
+
expect(setParametersSpy).toHaveBeenCalled();
|
|
408
|
+
expect(setParametersSpy.mock.calls[0][0].encodings).toEqual([
|
|
409
|
+
{
|
|
410
|
+
rid: 'q',
|
|
411
|
+
active: true,
|
|
412
|
+
maxBitrate: 50,
|
|
413
|
+
scaleResolutionDownBy: 1,
|
|
414
|
+
maxFramerate: 30,
|
|
415
|
+
scalabilityMode: 'L1T3',
|
|
416
|
+
},
|
|
417
|
+
]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('supports empty rid in SVC', async () => {
|
|
421
|
+
const transceiver = new RTCRtpTransceiver();
|
|
422
|
+
const setParametersSpy = vi
|
|
423
|
+
.spyOn(transceiver.sender, 'setParameters')
|
|
424
|
+
.mockResolvedValue();
|
|
425
|
+
const getParametersSpy = vi
|
|
426
|
+
.spyOn(transceiver.sender, 'getParameters')
|
|
427
|
+
.mockReturnValue({
|
|
428
|
+
codecs: [
|
|
429
|
+
// @ts-expect-error incomplete data
|
|
430
|
+
{ mimeType: 'video/VP9' },
|
|
431
|
+
],
|
|
432
|
+
encodings: [
|
|
433
|
+
{
|
|
434
|
+
rid: undefined, // empty rid
|
|
435
|
+
active: true,
|
|
436
|
+
// @ts-expect-error not in the standard lib yet
|
|
437
|
+
scalabilityMode: 'L3T3_KEY',
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// inject the transceiver
|
|
443
|
+
publisher['transceiverCache'].set(TrackType.VIDEO, transceiver);
|
|
444
|
+
|
|
445
|
+
await publisher['changePublishQuality']([
|
|
446
|
+
{
|
|
447
|
+
name: 'q',
|
|
448
|
+
active: true,
|
|
449
|
+
maxBitrate: 50,
|
|
450
|
+
scaleResolutionDownBy: 1,
|
|
451
|
+
maxFramerate: 30,
|
|
452
|
+
scalabilityMode: 'L1T3',
|
|
453
|
+
},
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
expect(getParametersSpy).toHaveBeenCalled();
|
|
457
|
+
expect(setParametersSpy).toHaveBeenCalled();
|
|
458
|
+
expect(setParametersSpy.mock.calls[0][0].encodings).toEqual([
|
|
459
|
+
{
|
|
460
|
+
active: true,
|
|
461
|
+
maxBitrate: 50,
|
|
462
|
+
scaleResolutionDownBy: 1,
|
|
463
|
+
maxFramerate: 30,
|
|
464
|
+
scalabilityMode: 'L1T3',
|
|
465
|
+
},
|
|
466
|
+
]);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
280
469
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getOptimalBitrate } from '../bitrateLookup';
|
|
3
|
+
|
|
4
|
+
describe('bitrateLookup', () => {
|
|
5
|
+
it('should return optimal bitrate', () => {
|
|
6
|
+
expect(getOptimalBitrate('vp9', 720)).toBe(1_250_000);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should return nearest bitrate for exotic dimensions', () => {
|
|
10
|
+
expect(getOptimalBitrate('vp9', 1000)).toBe(1_500_000);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
findOptimalScreenSharingLayers,
|
|
5
5
|
findOptimalVideoLayers,
|
|
6
6
|
getComputedMaxBitrate,
|
|
7
|
+
OptimalVideoLayer,
|
|
8
|
+
ridToVideoQuality,
|
|
9
|
+
toSvcEncodings,
|
|
7
10
|
} from '../videoLayers';
|
|
11
|
+
import { VideoQuality } from '../../gen/video/sfu/models/models';
|
|
8
12
|
|
|
9
13
|
describe('videoLayers', () => {
|
|
10
14
|
it('should find optimal screen sharing layers', () => {
|
|
@@ -135,15 +139,51 @@ describe('videoLayers', () => {
|
|
|
135
139
|
expect(layers[2].rid).toBe('f');
|
|
136
140
|
});
|
|
137
141
|
|
|
142
|
+
it('should announce only one layer for SVC codecs', () => {
|
|
143
|
+
const track = new MediaStreamTrack();
|
|
144
|
+
vi.spyOn(track, 'getSettings').mockReturnValue({
|
|
145
|
+
width: 1280,
|
|
146
|
+
height: 720,
|
|
147
|
+
});
|
|
148
|
+
const layers = findOptimalVideoLayers(track, undefined, 'vp9', {
|
|
149
|
+
preferredCodec: 'vp9',
|
|
150
|
+
scalabilityMode: 'L3T3',
|
|
151
|
+
});
|
|
152
|
+
expect(layers.length).toBe(3);
|
|
153
|
+
expect(layers[0].scalabilityMode).toBe('L3T3');
|
|
154
|
+
expect(layers[0].rid).toBe('q');
|
|
155
|
+
expect(layers[1].rid).toBe('h');
|
|
156
|
+
expect(layers[2].rid).toBe('f');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should map rids to VideoQuality', () => {
|
|
160
|
+
expect(ridToVideoQuality('q')).toBe(VideoQuality.LOW_UNSPECIFIED);
|
|
161
|
+
expect(ridToVideoQuality('h')).toBe(VideoQuality.MID);
|
|
162
|
+
expect(ridToVideoQuality('f')).toBe(VideoQuality.HIGH);
|
|
163
|
+
expect(ridToVideoQuality('')).toBe(VideoQuality.HIGH);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should map OptimalVideoLayer to SVC encodings', () => {
|
|
167
|
+
const layers: Array<Partial<OptimalVideoLayer>> = [
|
|
168
|
+
{ rid: 'f', width: 1920, height: 1080, maxBitrate: 3000000 },
|
|
169
|
+
{ rid: 'h', width: 960, height: 540, maxBitrate: 750000 },
|
|
170
|
+
{ rid: 'q', width: 480, height: 270, maxBitrate: 187500 },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const svcLayers = toSvcEncodings(layers as OptimalVideoLayer[]);
|
|
174
|
+
expect(svcLayers.length).toBe(1);
|
|
175
|
+
expect(svcLayers[0]).toEqual({
|
|
176
|
+
rid: 'q',
|
|
177
|
+
width: 1920,
|
|
178
|
+
height: 1080,
|
|
179
|
+
maxBitrate: 3000000,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
138
183
|
describe('getComputedMaxBitrate', () => {
|
|
139
184
|
it('should scale target bitrate down if resolution is smaller than target resolution', () => {
|
|
140
185
|
const targetResolution = { width: 1920, height: 1080, bitrate: 3000000 };
|
|
141
|
-
const scaledBitrate = getComputedMaxBitrate(
|
|
142
|
-
targetResolution,
|
|
143
|
-
1280,
|
|
144
|
-
720,
|
|
145
|
-
undefined,
|
|
146
|
-
);
|
|
186
|
+
const scaledBitrate = getComputedMaxBitrate(targetResolution, 1280, 720);
|
|
147
187
|
expect(scaledBitrate).toBe(1333333);
|
|
148
188
|
});
|
|
149
189
|
|
|
@@ -153,12 +193,7 @@ describe('videoLayers', () => {
|
|
|
153
193
|
const targetBitrates = ['f', 'h', 'q'].map((rid) => {
|
|
154
194
|
const width = targetResolution.width / downscaleFactor;
|
|
155
195
|
const height = targetResolution.height / downscaleFactor;
|
|
156
|
-
const bitrate = getComputedMaxBitrate(
|
|
157
|
-
targetResolution,
|
|
158
|
-
width,
|
|
159
|
-
height,
|
|
160
|
-
undefined,
|
|
161
|
-
);
|
|
196
|
+
const bitrate = getComputedMaxBitrate(targetResolution, width, height);
|
|
162
197
|
downscaleFactor *= 2;
|
|
163
198
|
return {
|
|
164
199
|
rid,
|
|
@@ -176,45 +211,25 @@ describe('videoLayers', () => {
|
|
|
176
211
|
|
|
177
212
|
it('should not scale target bitrate if resolution is larger than target resolution', () => {
|
|
178
213
|
const targetResolution = { width: 1280, height: 720, bitrate: 1000000 };
|
|
179
|
-
const scaledBitrate = getComputedMaxBitrate(
|
|
180
|
-
targetResolution,
|
|
181
|
-
2560,
|
|
182
|
-
1440,
|
|
183
|
-
undefined,
|
|
184
|
-
);
|
|
214
|
+
const scaledBitrate = getComputedMaxBitrate(targetResolution, 2560, 1440);
|
|
185
215
|
expect(scaledBitrate).toBe(1000000);
|
|
186
216
|
});
|
|
187
217
|
|
|
188
218
|
it('should not scale target bitrate if resolution is equal to target resolution', () => {
|
|
189
219
|
const targetResolution = { width: 1280, height: 720, bitrate: 1000000 };
|
|
190
|
-
const scaledBitrate = getComputedMaxBitrate(
|
|
191
|
-
targetResolution,
|
|
192
|
-
1280,
|
|
193
|
-
720,
|
|
194
|
-
undefined,
|
|
195
|
-
);
|
|
220
|
+
const scaledBitrate = getComputedMaxBitrate(targetResolution, 1280, 720);
|
|
196
221
|
expect(scaledBitrate).toBe(1000000);
|
|
197
222
|
});
|
|
198
223
|
|
|
199
224
|
it('should handle 0 width and height', () => {
|
|
200
225
|
const targetResolution = { width: 1280, height: 720, bitrate: 1000000 };
|
|
201
|
-
const scaledBitrate = getComputedMaxBitrate(
|
|
202
|
-
targetResolution,
|
|
203
|
-
0,
|
|
204
|
-
0,
|
|
205
|
-
undefined,
|
|
206
|
-
);
|
|
226
|
+
const scaledBitrate = getComputedMaxBitrate(targetResolution, 0, 0);
|
|
207
227
|
expect(scaledBitrate).toBe(0);
|
|
208
228
|
});
|
|
209
229
|
|
|
210
230
|
it('should handle 4k target resolution', () => {
|
|
211
231
|
const targetResolution = { width: 3840, height: 2160, bitrate: 15000000 };
|
|
212
|
-
const scaledBitrate = getComputedMaxBitrate(
|
|
213
|
-
targetResolution,
|
|
214
|
-
1280,
|
|
215
|
-
720,
|
|
216
|
-
undefined,
|
|
217
|
-
);
|
|
232
|
+
const scaledBitrate = getComputedMaxBitrate(targetResolution, 1280, 720);
|
|
218
233
|
expect(scaledBitrate).toBe(1666667);
|
|
219
234
|
});
|
|
220
235
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { PreferredCodec } from '../types';
|
|
2
|
+
|
|
3
|
+
const bitrateLookupTable: Record<
|
|
4
|
+
PreferredCodec,
|
|
5
|
+
Record<number | 'default', number | undefined> | undefined
|
|
6
|
+
> = {
|
|
7
|
+
h264: {
|
|
8
|
+
2160: 5_000_000,
|
|
9
|
+
1440: 3_500_000,
|
|
10
|
+
1080: 2_750_000,
|
|
11
|
+
720: 1_250_000,
|
|
12
|
+
540: 750_000,
|
|
13
|
+
360: 400_000,
|
|
14
|
+
default: 1_250_000,
|
|
15
|
+
},
|
|
16
|
+
vp8: {
|
|
17
|
+
2160: 5_000_000,
|
|
18
|
+
1440: 2_750_000,
|
|
19
|
+
1080: 2_000_000,
|
|
20
|
+
720: 1_250_000,
|
|
21
|
+
540: 600_000,
|
|
22
|
+
360: 350_000,
|
|
23
|
+
default: 1_250_000,
|
|
24
|
+
},
|
|
25
|
+
vp9: {
|
|
26
|
+
2160: 3_000_000,
|
|
27
|
+
1440: 2_000_000,
|
|
28
|
+
1080: 1_500_000,
|
|
29
|
+
720: 1_250_000,
|
|
30
|
+
540: 500_000,
|
|
31
|
+
360: 275_000,
|
|
32
|
+
default: 1_250_000,
|
|
33
|
+
},
|
|
34
|
+
av1: {
|
|
35
|
+
2160: 2_000_000,
|
|
36
|
+
1440: 1_550_000,
|
|
37
|
+
1080: 1_000_000,
|
|
38
|
+
720: 600_000,
|
|
39
|
+
540: 350_000,
|
|
40
|
+
360: 200_000,
|
|
41
|
+
default: 600_000,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const getOptimalBitrate = (
|
|
46
|
+
codec: PreferredCodec,
|
|
47
|
+
frameHeight: number,
|
|
48
|
+
): number => {
|
|
49
|
+
const codecLookup = bitrateLookupTable[codec];
|
|
50
|
+
if (!codecLookup) throw new Error(`Unknown codec: ${codec}`);
|
|
51
|
+
|
|
52
|
+
let bitrate = codecLookup[frameHeight];
|
|
53
|
+
if (!bitrate) {
|
|
54
|
+
const keys = Object.keys(codecLookup).map(Number);
|
|
55
|
+
const nearest = keys.reduce((a, b) =>
|
|
56
|
+
Math.abs(b - frameHeight) < Math.abs(a - frameHeight) ? b : a,
|
|
57
|
+
);
|
|
58
|
+
bitrate = codecLookup[nearest];
|
|
59
|
+
}
|
|
60
|
+
return bitrate ?? codecLookup.default!;
|
|
61
|
+
};
|
package/src/rtc/codecs.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { getOSInfo } from '../client-details';
|
|
2
|
+
import { isReactNative } from '../helpers/platforms';
|
|
3
|
+
import { isFirefox, isSafari } from '../helpers/browsers';
|
|
4
|
+
import type { PreferredCodec } from '../types';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Returns back a list of sorted codecs, with the preferred codec first.
|
|
@@ -92,14 +95,58 @@ export const getGenericSdp = async (direction: RTCRtpTransceiverDirection) => {
|
|
|
92
95
|
};
|
|
93
96
|
|
|
94
97
|
/**
|
|
95
|
-
* Returns the optimal codec for
|
|
98
|
+
* Returns the optimal video codec for the device.
|
|
96
99
|
*/
|
|
97
|
-
export const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
export const getOptimalVideoCodec = (
|
|
101
|
+
preferredCodec: PreferredCodec | undefined,
|
|
102
|
+
): PreferredCodec => {
|
|
103
|
+
if (isReactNative()) {
|
|
104
|
+
const os = getOSInfo()?.name.toLowerCase();
|
|
105
|
+
if (os === 'android') return preferredOr(preferredCodec, 'vp8');
|
|
106
|
+
if (os === 'ios' || os === 'ipados') return 'h264';
|
|
107
|
+
return preferredOr(preferredCodec, 'h264');
|
|
108
|
+
}
|
|
109
|
+
if (isSafari()) return 'h264';
|
|
110
|
+
if (isFirefox()) return 'vp8';
|
|
111
|
+
return preferredOr(preferredCodec, 'vp8');
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Determines if the platform supports the preferred codec.
|
|
116
|
+
* If not, it returns the fallback codec.
|
|
117
|
+
*/
|
|
118
|
+
const preferredOr = (
|
|
119
|
+
codec: PreferredCodec | undefined,
|
|
120
|
+
fallback: PreferredCodec,
|
|
121
|
+
): PreferredCodec => {
|
|
122
|
+
if (!codec) return fallback;
|
|
123
|
+
if (!('getCapabilities' in RTCRtpSender)) return fallback;
|
|
124
|
+
const capabilities = RTCRtpSender.getCapabilities('video');
|
|
125
|
+
if (!capabilities) return fallback;
|
|
126
|
+
|
|
127
|
+
// Safari and Firefox do not have a good support encoding to SVC codecs,
|
|
128
|
+
// so we disable it for them.
|
|
129
|
+
if (isSvcCodec(codec) && (isSafari() || isFirefox())) return fallback;
|
|
130
|
+
|
|
131
|
+
const { codecs } = capabilities;
|
|
132
|
+
const codecMimeType = `video/${codec}`.toLowerCase();
|
|
133
|
+
return codecs.some((c) => c.mimeType.toLowerCase() === codecMimeType)
|
|
134
|
+
? codec
|
|
135
|
+
: fallback;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Returns whether the codec is an SVC codec.
|
|
140
|
+
*
|
|
141
|
+
* @param codecOrMimeType the codec to check.
|
|
142
|
+
*/
|
|
143
|
+
export const isSvcCodec = (codecOrMimeType: string | undefined) => {
|
|
144
|
+
if (!codecOrMimeType) return false;
|
|
145
|
+
codecOrMimeType = codecOrMimeType.toLowerCase();
|
|
146
|
+
return (
|
|
147
|
+
codecOrMimeType === 'vp9' ||
|
|
148
|
+
codecOrMimeType === 'av1' ||
|
|
149
|
+
codecOrMimeType === 'video/vp9' ||
|
|
150
|
+
codecOrMimeType === 'video/av1'
|
|
151
|
+
);
|
|
105
152
|
};
|
package/src/rtc/videoLayers.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { PublishOptions } from '../types';
|
|
1
|
+
import { PreferredCodec, PublishOptions } from '../types';
|
|
2
2
|
import { TargetResolutionResponse } from '../gen/shims';
|
|
3
|
+
import { isSvcCodec } from './codecs';
|
|
4
|
+
import { getOptimalBitrate } from './bitrateLookup';
|
|
5
|
+
import { VideoQuality } from '../gen/video/sfu/models/models';
|
|
3
6
|
|
|
4
7
|
export type OptimalVideoLayer = RTCRtpEncodingParameters & {
|
|
5
8
|
width: number;
|
|
6
9
|
height: number;
|
|
10
|
+
// NOTE OL: should be part of RTCRtpEncodingParameters
|
|
11
|
+
scalabilityMode?: string;
|
|
7
12
|
};
|
|
8
13
|
|
|
9
14
|
const DEFAULT_BITRATE = 1250000;
|
|
@@ -19,48 +24,85 @@ const defaultBitratePerRid: Record<string, number> = {
|
|
|
19
24
|
f: DEFAULT_BITRATE,
|
|
20
25
|
};
|
|
21
26
|
|
|
27
|
+
/**
|
|
28
|
+
* In SVC, we need to send only one video encoding (layer).
|
|
29
|
+
* this layer will have the additional spatial and temporal layers
|
|
30
|
+
* defined via the scalabilityMode property.
|
|
31
|
+
*
|
|
32
|
+
* @param layers the layers to process.
|
|
33
|
+
*/
|
|
34
|
+
export const toSvcEncodings = (layers: OptimalVideoLayer[] | undefined) => {
|
|
35
|
+
// we take the `f` layer, and we rename it to `q`.
|
|
36
|
+
return layers?.filter((l) => l.rid === 'f').map((l) => ({ ...l, rid: 'q' }));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Converts the rid to a video quality.
|
|
41
|
+
*/
|
|
42
|
+
export const ridToVideoQuality = (rid: string): VideoQuality => {
|
|
43
|
+
return rid === 'q'
|
|
44
|
+
? VideoQuality.LOW_UNSPECIFIED
|
|
45
|
+
: rid === 'h'
|
|
46
|
+
? VideoQuality.MID
|
|
47
|
+
: VideoQuality.HIGH; // default to HIGH
|
|
48
|
+
};
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* Determines the most optimal video layers for simulcasting
|
|
24
52
|
* for the given track.
|
|
25
53
|
*
|
|
26
54
|
* @param videoTrack the video track to find optimal layers for.
|
|
27
55
|
* @param targetResolution the expected target resolution.
|
|
56
|
+
* @param codecInUse the codec in use.
|
|
28
57
|
* @param publishOptions the publish options for the track.
|
|
29
58
|
*/
|
|
30
59
|
export const findOptimalVideoLayers = (
|
|
31
60
|
videoTrack: MediaStreamTrack,
|
|
32
61
|
targetResolution: TargetResolutionResponse = defaultTargetResolution,
|
|
62
|
+
codecInUse?: PreferredCodec,
|
|
33
63
|
publishOptions?: PublishOptions,
|
|
34
64
|
) => {
|
|
35
65
|
const optimalVideoLayers: OptimalVideoLayer[] = [];
|
|
36
66
|
const settings = videoTrack.getSettings();
|
|
37
|
-
const { width
|
|
38
|
-
const {
|
|
67
|
+
const { width = 0, height = 0 } = settings;
|
|
68
|
+
const { scalabilityMode, bitrateDownscaleFactor = 2 } = publishOptions || {};
|
|
39
69
|
const maxBitrate = getComputedMaxBitrate(
|
|
40
70
|
targetResolution,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
width,
|
|
72
|
+
height,
|
|
73
|
+
codecInUse,
|
|
74
|
+
publishOptions,
|
|
44
75
|
);
|
|
45
76
|
let downscaleFactor = 1;
|
|
46
77
|
let bitrateFactor = 1;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// Encodings should be ordered in increasing spatial resolution order.
|
|
51
|
-
optimalVideoLayers.unshift({
|
|
78
|
+
const svcCodec = isSvcCodec(codecInUse);
|
|
79
|
+
for (const rid of ['f', 'h', 'q']) {
|
|
80
|
+
const layer: OptimalVideoLayer = {
|
|
52
81
|
active: true,
|
|
53
82
|
rid,
|
|
54
|
-
width: Math.round(
|
|
55
|
-
height: Math.round(
|
|
83
|
+
width: Math.round(width / downscaleFactor),
|
|
84
|
+
height: Math.round(height / downscaleFactor),
|
|
56
85
|
maxBitrate:
|
|
57
86
|
Math.round(maxBitrate / bitrateFactor) || defaultBitratePerRid[rid],
|
|
58
|
-
scaleResolutionDownBy: downscaleFactor,
|
|
59
87
|
maxFramerate: 30,
|
|
60
|
-
}
|
|
88
|
+
};
|
|
89
|
+
if (svcCodec) {
|
|
90
|
+
// for SVC codecs, we need to set the scalability mode, and the
|
|
91
|
+
// codec will handle the rest (layers, temporal layers, etc.)
|
|
92
|
+
layer.scalabilityMode = scalabilityMode || 'L3T2_KEY';
|
|
93
|
+
} else {
|
|
94
|
+
// for non-SVC codecs, we need to downscale proportionally (simulcast)
|
|
95
|
+
layer.scaleResolutionDownBy = downscaleFactor;
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
downscaleFactor *= 2;
|
|
62
99
|
bitrateFactor *= bitrateDownscaleFactor;
|
|
63
|
-
|
|
100
|
+
|
|
101
|
+
// Reversing the order [f, h, q] to [q, h, f] as Chrome uses encoding index
|
|
102
|
+
// when deciding which layer to disable when CPU or bandwidth is constrained.
|
|
103
|
+
// Encodings should be ordered in increasing spatial resolution order.
|
|
104
|
+
optimalVideoLayers.unshift(layer);
|
|
105
|
+
}
|
|
64
106
|
|
|
65
107
|
// for simplicity, we start with all layers enabled, then this function
|
|
66
108
|
// will clear/reassign the layers that are not needed
|
|
@@ -77,13 +119,15 @@ export const findOptimalVideoLayers = (
|
|
|
77
119
|
* @param targetResolution the target resolution.
|
|
78
120
|
* @param currentWidth the current width of the track.
|
|
79
121
|
* @param currentHeight the current height of the track.
|
|
80
|
-
* @param
|
|
122
|
+
* @param codecInUse the codec in use.
|
|
123
|
+
* @param publishOptions the publish options.
|
|
81
124
|
*/
|
|
82
125
|
export const getComputedMaxBitrate = (
|
|
83
126
|
targetResolution: TargetResolutionResponse,
|
|
84
127
|
currentWidth: number,
|
|
85
128
|
currentHeight: number,
|
|
86
|
-
|
|
129
|
+
codecInUse?: PreferredCodec,
|
|
130
|
+
publishOptions?: PublishOptions,
|
|
87
131
|
): number => {
|
|
88
132
|
// if the current resolution is lower than the target resolution,
|
|
89
133
|
// we want to proportionally reduce the target bitrate
|
|
@@ -92,7 +136,12 @@ export const getComputedMaxBitrate = (
|
|
|
92
136
|
height: targetHeight,
|
|
93
137
|
bitrate: targetBitrate,
|
|
94
138
|
} = targetResolution;
|
|
95
|
-
const
|
|
139
|
+
const { preferredBitrate } = publishOptions || {};
|
|
140
|
+
const frameHeight =
|
|
141
|
+
currentWidth > currentHeight ? currentHeight : currentWidth;
|
|
142
|
+
const bitrate =
|
|
143
|
+
preferredBitrate ||
|
|
144
|
+
(codecInUse ? getOptimalBitrate(codecInUse, frameHeight) : targetBitrate);
|
|
96
145
|
if (currentWidth < targetWidth || currentHeight < targetHeight) {
|
|
97
146
|
const currentPixels = currentWidth * currentHeight;
|
|
98
147
|
const targetPixels = targetWidth * targetHeight;
|