livekit-client 2.19.1 → 2.20.0
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/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +204 -145
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +664 -526
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +2 -0
- package/dist/{livekit-client.pt.worker.js.map → livekit-client.fm.worker.js.map} +1 -1
- package/dist/{livekit-client.pt.worker.mjs → livekit-client.fm.worker.mjs} +133 -99
- package/dist/livekit-client.fm.worker.mjs.map +1 -0
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/types.d.ts +5 -5
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts +20 -10
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/{packetTrailer/PacketTrailerManager.d.ts → frameMetadata/FrameMetadataManager.d.ts} +8 -4
- package/dist/src/{packetTrailer/PacketTrailerManager.d.ts.map → frameMetadata/FrameMetadataManager.d.ts.map} +1 -1
- package/dist/src/{packetTrailer/packetTrailer.d.ts → frameMetadata/frameMetadata.d.ts} +7 -7
- package/dist/src/frameMetadata/frameMetadata.d.ts.map +1 -0
- package/dist/src/{packetTrailer → frameMetadata}/types.d.ts +10 -6
- package/dist/src/frameMetadata/types.d.ts.map +1 -0
- package/dist/src/frameMetadata/utils.d.ts +9 -0
- package/dist/src/{packetTrailer → frameMetadata}/utils.d.ts.map +1 -1
- package/dist/src/frameMetadata/worker/frameMetadata.worker.d.ts +2 -0
- package/dist/src/frameMetadata/worker/frameMetadata.worker.d.ts.map +1 -0
- package/dist/src/index.d.ts +10 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/options.d.ts +8 -4
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +1 -0
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts +1 -0
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +11 -1
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +2 -2
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +2 -2
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
- package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
- package/dist/src/room/token-source/types.d.ts +1 -0
- package/dist/src/room/token-source/types.d.ts.map +1 -1
- package/dist/src/room/token-source/utils.d.ts.map +1 -1
- package/dist/src/room/track/FrameMetadataExtractor.d.ts +19 -0
- package/dist/src/room/track/FrameMetadataExtractor.d.ts.map +1 -0
- package/dist/src/room/track/RemoteVideoTrack.d.ts +7 -7
- package/dist/src/room/track/RemoteVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +17 -5
- package/dist/src/room/track/options.d.ts.map +1 -1
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +1 -1
- package/dist/ts4.2/e2ee/types.d.ts +5 -5
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +19 -9
- package/dist/ts4.2/{packetTrailer/PacketTrailerManager.d.ts → frameMetadata/FrameMetadataManager.d.ts} +8 -4
- package/dist/ts4.2/{packetTrailer/packetTrailer.d.ts → frameMetadata/frameMetadata.d.ts} +7 -7
- package/dist/ts4.2/{packetTrailer → frameMetadata}/types.d.ts +10 -6
- package/dist/ts4.2/frameMetadata/utils.d.ts +9 -0
- package/dist/ts4.2/frameMetadata/worker/frameMetadata.worker.d.ts +2 -0
- package/dist/ts4.2/index.d.ts +9 -3
- package/dist/ts4.2/options.d.ts +8 -4
- package/dist/ts4.2/room/PCTransport.d.ts +1 -0
- package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
- package/dist/ts4.2/room/RTCEngine.d.ts +11 -1
- package/dist/ts4.2/room/Room.d.ts +2 -2
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +2 -2
- package/dist/ts4.2/room/token-source/types.d.ts +1 -0
- package/dist/ts4.2/room/track/FrameMetadataExtractor.d.ts +19 -0
- package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +7 -7
- package/dist/ts4.2/room/track/options.d.ts +17 -5
- package/package.json +25 -20
- package/src/api/SignalClient.test.ts +4 -4
- package/src/api/SignalClient.ts +7 -3
- package/src/api/WebSocketStream.test.ts +7 -7
- package/src/e2ee/E2eeManager.ts +13 -11
- package/src/e2ee/types.ts +5 -5
- package/src/e2ee/worker/FrameCryptor.test.ts +5 -5
- package/src/e2ee/worker/FrameCryptor.ts +69 -30
- package/src/e2ee/worker/e2ee.worker.ts +5 -5
- package/src/e2ee/worker/tsconfig.json +4 -1
- package/src/{packetTrailer/PacketTrailerManager.test.ts → frameMetadata/FrameMetadataManager.test.ts} +10 -8
- package/src/{packetTrailer/PacketTrailerManager.ts → frameMetadata/FrameMetadataManager.ts} +21 -15
- package/src/{packetTrailer/packetTrailer.test.ts → frameMetadata/frameMetadata.test.ts} +1 -1
- package/src/{packetTrailer/packetTrailer.ts → frameMetadata/frameMetadata.ts} +9 -9
- package/src/{packetTrailer → frameMetadata}/types.ts +12 -6
- package/src/{packetTrailer → frameMetadata}/utils.test.ts +19 -19
- package/src/{packetTrailer → frameMetadata}/utils.ts +11 -11
- package/src/{packetTrailer/worker/packetTrailer.worker.ts → frameMetadata/worker/frameMetadata.worker.ts} +5 -5
- package/src/{packetTrailer → frameMetadata}/worker/tsconfig.json +4 -1
- package/src/index.ts +13 -2
- package/src/options.ts +9 -4
- package/src/room/PCTransport.ts +4 -0
- package/src/room/PCTransportManager.ts +4 -0
- package/src/room/RTCEngine.test.ts +143 -9
- package/src/room/RTCEngine.ts +156 -48
- package/src/room/Room.ts +10 -10
- package/src/room/participant/LocalParticipant.test.ts +19 -19
- package/src/room/participant/LocalParticipant.ts +21 -16
- package/src/room/participant/publishUtils.test.ts +6 -0
- package/src/room/participant/publishUtils.ts +4 -1
- package/src/room/rpc/client/RpcClientManager.ts +6 -3
- package/src/room/rpc/server/RpcServerManager.ts +1 -1
- package/src/room/token-source/TokenSource.ts +9 -23
- package/src/room/token-source/types.ts +1 -0
- package/src/room/token-source/utils.ts +1 -0
- package/src/room/track/{PacketTrailerExtractor.ts → FrameMetadataExtractor.ts} +8 -8
- package/src/room/track/RemoteVideoTrack.ts +7 -11
- package/src/room/track/options.ts +19 -5
- package/dist/livekit-client.pt.worker.js +0 -2
- package/dist/livekit-client.pt.worker.mjs.map +0 -1
- package/dist/src/packetTrailer/packetTrailer.d.ts.map +0 -1
- package/dist/src/packetTrailer/types.d.ts.map +0 -1
- package/dist/src/packetTrailer/utils.d.ts +0 -9
- package/dist/src/packetTrailer/worker/packetTrailer.worker.d.ts +0 -2
- package/dist/src/packetTrailer/worker/packetTrailer.worker.d.ts.map +0 -1
- package/dist/src/room/track/PacketTrailerExtractor.d.ts +0 -19
- package/dist/src/room/track/PacketTrailerExtractor.d.ts.map +0 -1
- package/dist/ts4.2/packetTrailer/utils.d.ts +0 -9
- package/dist/ts4.2/packetTrailer/worker/packetTrailer.worker.d.ts +0 -2
- package/dist/ts4.2/room/track/PacketTrailerExtractor.d.ts +0 -19
package/src/room/RTCEngine.ts
CHANGED
|
@@ -54,13 +54,13 @@ import {
|
|
|
54
54
|
} from '../api/SignalClient';
|
|
55
55
|
import type { BaseE2EEManager } from '../e2ee/E2eeManager';
|
|
56
56
|
import { asEncryptablePacket, isInsertableStreamSupported } from '../e2ee/utils';
|
|
57
|
+
import {
|
|
58
|
+
hasFrameMetadataPublishOptions,
|
|
59
|
+
isFrameMetadataSupported,
|
|
60
|
+
shouldUseFrameMetadataScriptTransform,
|
|
61
|
+
} from '../frameMetadata/utils';
|
|
57
62
|
import log, { LoggerNames, getLogger } from '../logger';
|
|
58
63
|
import type { InternalRoomOptions } from '../options';
|
|
59
|
-
import {
|
|
60
|
-
hasPacketTrailerPublishOptions,
|
|
61
|
-
isPacketTrailerSupported,
|
|
62
|
-
shouldUsePacketTrailerScriptTransform,
|
|
63
|
-
} from '../packetTrailer/utils';
|
|
64
64
|
import TypedPromise from '../utils/TypedPromise';
|
|
65
65
|
import { DataPacketBuffer } from '../utils/dataPacketBuffer';
|
|
66
66
|
import { TTLMap } from '../utils/ttlmap';
|
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
ConnectionError,
|
|
74
74
|
ConnectionErrorReason,
|
|
75
75
|
NegotiationError,
|
|
76
|
+
PublishDataError,
|
|
76
77
|
SignalReconnectError,
|
|
77
78
|
TrackInvalidError,
|
|
78
79
|
UnexpectedConnectionState,
|
|
@@ -91,6 +92,7 @@ import type { LoggerOptions } from './types';
|
|
|
91
92
|
import {
|
|
92
93
|
Future,
|
|
93
94
|
isPublisherOfferWithJoinSupported,
|
|
95
|
+
isReactNative,
|
|
94
96
|
isVideoCodec,
|
|
95
97
|
isVideoTrack,
|
|
96
98
|
isWeb,
|
|
@@ -108,6 +110,7 @@ const leaveReconnect = 'leave-reconnect';
|
|
|
108
110
|
const reliabeReceiveStateTTL = 30_000;
|
|
109
111
|
const lossyDataChannelBufferThresholdMin = 8 * 1024;
|
|
110
112
|
const lossyDataChannelBufferThresholdMax = 256 * 1024;
|
|
113
|
+
|
|
111
114
|
const initialMediaSectionsAudio = 3;
|
|
112
115
|
const initialMediaSectionsVideo = 3;
|
|
113
116
|
|
|
@@ -125,6 +128,11 @@ export enum DataChannelKind {
|
|
|
125
128
|
DATA_TRACK_LOSSY = 2,
|
|
126
129
|
}
|
|
127
130
|
|
|
131
|
+
// Default data-channel max message size (bytes), used when the remote SDP
|
|
132
|
+
// answer does not advertise an `a=max-message-size` attribute (RFC 8841).
|
|
133
|
+
// `0` means "no limit".
|
|
134
|
+
const DEFAULT_MAX_MESSAGE_SIZE = 64_000;
|
|
135
|
+
|
|
128
136
|
/** @internal */
|
|
129
137
|
export default class RTCEngine extends (EventEmitter as new () => TypedEventEmitter<EngineEventCallbacks>) {
|
|
130
138
|
client: SignalClient;
|
|
@@ -324,12 +332,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
324
332
|
this.joinAttempts += 1;
|
|
325
333
|
|
|
326
334
|
this.setupSignalClientCallbacks();
|
|
335
|
+
// Whether the initial publisher offer is bundled with the join request. Computed once and
|
|
336
|
+
// reused after the join below. Only the (non-Firefox) offer-with-join path does this.
|
|
337
|
+
const sendOfferWithJoin = !useV0Path && isPublisherOfferWithJoinSupported();
|
|
338
|
+
|
|
327
339
|
let offerProto: SessionDescription | undefined;
|
|
328
|
-
if (
|
|
340
|
+
if (sendOfferWithJoin) {
|
|
329
341
|
if (!this.pcManager) {
|
|
342
|
+
// Firefox is excluded from offer-with-join (see isPublisherOfferWithJoinSupported):
|
|
343
|
+
// customers reported ICE connectivity problems for FF on this path (#1919) that we were
|
|
344
|
+
// never able to reproduce, so out of caution FF stays on the deferred path below. The
|
|
345
|
+
// exact cause is unknown — note that ICE gathering does not actually start here, since
|
|
346
|
+
// createInitialOffer() defers setLocalDescription (via pendingInitialOffer) until the
|
|
347
|
+
// answer is applied, after updateConfiguration() has set the server's TURN servers.
|
|
330
348
|
await this.configure();
|
|
331
|
-
this.
|
|
332
|
-
this.addMediaSections(initialMediaSectionsAudio, initialMediaSectionsVideo);
|
|
349
|
+
this.applyInitialPublisherLayout();
|
|
333
350
|
}
|
|
334
351
|
const offer = await this.pcManager?.publisher.createInitialOffer();
|
|
335
352
|
if (offer) {
|
|
@@ -358,11 +375,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
358
375
|
this.participantSid = joinResponse.participant?.sid;
|
|
359
376
|
|
|
360
377
|
this.subscriberPrimary = joinResponse.subscriberPrimary;
|
|
361
|
-
if (
|
|
378
|
+
if (sendOfferWithJoin) {
|
|
362
379
|
this.pcManager?.updateConfiguration(this.makeRTCConfiguration(joinResponse));
|
|
363
380
|
} else {
|
|
364
381
|
if (!this.pcManager) {
|
|
382
|
+
// Deferred path (Firefox, and V0): configure with the join response so the PC picks up
|
|
383
|
+
// the server's ICE servers and topology, then negotiate separately rather than bundling
|
|
384
|
+
// the offer with the join.
|
|
365
385
|
await this.configure(joinResponse, !useV0Path);
|
|
386
|
+
if (!useV0Path) {
|
|
387
|
+
// The V1 first offer must carry the media layout so Firefox binds receive decoders for
|
|
388
|
+
// subscribed tracks — without it, subscribed audio/video arrive as RTP but
|
|
389
|
+
// never decode. V0 (legacy dual-PC) keeps its original lazy behavior.
|
|
390
|
+
this.applyInitialPublisherLayout();
|
|
391
|
+
}
|
|
366
392
|
}
|
|
367
393
|
// create offer
|
|
368
394
|
if (!this.subscriberPrimary || joinResponse.fastPublish) {
|
|
@@ -431,18 +457,25 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
431
457
|
}
|
|
432
458
|
|
|
433
459
|
async cleanupPeerConnections() {
|
|
434
|
-
await this.pcManager?.close();
|
|
435
|
-
this.pcManager = undefined;
|
|
436
|
-
|
|
437
460
|
const dcCleanup = (dc: RTCDataChannel | undefined) => {
|
|
438
|
-
if (!dc)
|
|
439
|
-
|
|
461
|
+
if (!dc) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Detach the data channel handlers before closing anything. Closing a peer connection tears
|
|
466
|
+
// down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
|
|
467
|
+
// channels; if our handlers are still attached at that point, handleDataError logs a spurious
|
|
468
|
+
// "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
|
|
469
|
+
// before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
|
|
470
|
+
// browser dispatches those teardown events. See livekit/client-sdk-js#1953.
|
|
440
471
|
dc.onbufferedamountlow = null;
|
|
441
472
|
dc.onclose = null;
|
|
442
473
|
dc.onclosing = null;
|
|
443
474
|
dc.onerror = null;
|
|
444
475
|
dc.onmessage = null;
|
|
445
476
|
dc.onopen = null;
|
|
477
|
+
|
|
478
|
+
dc.close();
|
|
446
479
|
};
|
|
447
480
|
dcCleanup(this.lossyDC);
|
|
448
481
|
dcCleanup(this.lossyDCSub);
|
|
@@ -451,6 +484,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
451
484
|
dcCleanup(this.dataTrackDC);
|
|
452
485
|
dcCleanup(this.dataTrackDCSub);
|
|
453
486
|
|
|
487
|
+
await this.pcManager?.close();
|
|
488
|
+
this.pcManager = undefined;
|
|
489
|
+
|
|
454
490
|
this.lossyDC = undefined;
|
|
455
491
|
this.lossyDCSub = undefined;
|
|
456
492
|
this.reliableDC = undefined;
|
|
@@ -770,7 +806,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
770
806
|
// used; RTCRtpScriptTransform does not need the PeerConnection flag.
|
|
771
807
|
const needsInsertableStreams =
|
|
772
808
|
this.signalOpts?.e2eeEnabled ||
|
|
773
|
-
(this.
|
|
809
|
+
(this.frameMetadataWorker && !shouldUseFrameMetadataScriptTransform());
|
|
774
810
|
|
|
775
811
|
if (needsInsertableStreams && isInsertableStreamSupported()) {
|
|
776
812
|
this.log.debug('E2EE - setting up transports with insertable streams');
|
|
@@ -814,6 +850,25 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
814
850
|
return rtcConfig;
|
|
815
851
|
}
|
|
816
852
|
|
|
853
|
+
/**
|
|
854
|
+
* Populate the publisher PC so its first offer carries the data channels + recvonly media
|
|
855
|
+
* sections. Required for every V1 connection: Firefox only binds receive decoders for media
|
|
856
|
+
* present in that first offer, and the offer-with-join path needs the sections to
|
|
857
|
+
* build a meaningful initial offer. Must be called on a configured pcManager.
|
|
858
|
+
*/
|
|
859
|
+
private applyInitialPublisherLayout() {
|
|
860
|
+
this.createDataChannels();
|
|
861
|
+
/**
|
|
862
|
+
* Native libwebrtc does not support pre-populating the media sections,
|
|
863
|
+
* so we skip it for React Native.
|
|
864
|
+
*
|
|
865
|
+
* Related: https://github.com/livekit/rust-sdks/pull/1151
|
|
866
|
+
*/
|
|
867
|
+
if (!isReactNative()) {
|
|
868
|
+
this.addMediaSections(initialMediaSectionsAudio, initialMediaSectionsVideo);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
817
872
|
private addMediaSections(numAudios: number, numVideos: number) {
|
|
818
873
|
const transceiverInit: RTCRtpTransceiverInit = { direction: 'recvonly' };
|
|
819
874
|
for (let i: number = 0; i < numAudios; i++) {
|
|
@@ -833,14 +888,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
833
888
|
if (this.lossyDC) {
|
|
834
889
|
this.lossyDC.onmessage = null;
|
|
835
890
|
this.lossyDC.onerror = null;
|
|
891
|
+
this.lossyDC.onclose = null;
|
|
836
892
|
}
|
|
837
893
|
if (this.reliableDC) {
|
|
838
894
|
this.reliableDC.onmessage = null;
|
|
839
895
|
this.reliableDC.onerror = null;
|
|
896
|
+
this.reliableDC.onclose = null;
|
|
840
897
|
}
|
|
841
898
|
if (this.dataTrackDC) {
|
|
842
899
|
this.dataTrackDC.onmessage = null;
|
|
843
900
|
this.dataTrackDC.onerror = null;
|
|
901
|
+
this.dataTrackDC.onclose = null;
|
|
844
902
|
}
|
|
845
903
|
|
|
846
904
|
// create data channels
|
|
@@ -866,6 +924,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
866
924
|
this.reliableDC.onerror = this.handleDataError;
|
|
867
925
|
this.dataTrackDC.onerror = this.handleDataError;
|
|
868
926
|
|
|
927
|
+
// detect unexpected publisher data channel closes
|
|
928
|
+
this.lossyDC.onclose = this.handleDataChannelClose(DataChannelKind.LOSSY);
|
|
929
|
+
this.reliableDC.onclose = this.handleDataChannelClose(DataChannelKind.RELIABLE);
|
|
930
|
+
this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY);
|
|
931
|
+
|
|
869
932
|
// set up dc buffer threshold, set to 64kB (otherwise 0 by default)
|
|
870
933
|
this.lossyDC.bufferedAmountLowThreshold = 65535;
|
|
871
934
|
this.reliableDC.bufferedAmountLowThreshold = 65535;
|
|
@@ -994,17 +1057,41 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
994
1057
|
};
|
|
995
1058
|
|
|
996
1059
|
private handleDataError = (event: Event) => {
|
|
1060
|
+
// Errors fired while we're tearing the connection down (e.g. the SCTP transport aborting as
|
|
1061
|
+
// the peer connection closes) carry no actionable information — the channel is going away
|
|
1062
|
+
// regardless. Suppress them so a graceful disconnect doesn't surface spurious errors.
|
|
1063
|
+
// See livekit/client-sdk-js#1953.
|
|
1064
|
+
if (this._isClosed) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
997
1068
|
const channel = event.currentTarget as RTCDataChannel;
|
|
998
1069
|
const channelKind = channel.maxRetransmits === 0 ? 'lossy' : 'reliable';
|
|
999
1070
|
|
|
1000
|
-
if (event instanceof
|
|
1001
|
-
const { error } = event
|
|
1002
|
-
this.log.error(`DataChannel error on ${channelKind}: ${
|
|
1071
|
+
if (typeof RTCErrorEvent !== 'undefined' && event instanceof RTCErrorEvent && event.error) {
|
|
1072
|
+
const { error } = event;
|
|
1073
|
+
this.log.error(`DataChannel error on ${channelKind}: ${error.message}`, {
|
|
1074
|
+
error,
|
|
1075
|
+
errorDetail: error.errorDetail,
|
|
1076
|
+
sctpCauseCode: error.sctpCauseCode,
|
|
1077
|
+
});
|
|
1003
1078
|
} else {
|
|
1004
1079
|
this.log.error(`Unknown DataChannel error on ${channelKind}`, { event });
|
|
1005
1080
|
}
|
|
1006
1081
|
};
|
|
1007
1082
|
|
|
1083
|
+
private handleDataChannelClose = (kind: DataChannelKind) => () => {
|
|
1084
|
+
// A publisher DC closing while the session is up and the publisher PC is still
|
|
1085
|
+
// connected is the signature of an oversized message having aborted the channel
|
|
1086
|
+
// (see livekit/rust-sdks#1137). Surface it; do not attempt renegotiation.
|
|
1087
|
+
if (!this._isClosed && this.pcManager?.publisher.getConnectionState() === 'connected') {
|
|
1088
|
+
this.log.error(
|
|
1089
|
+
`publisher data channel '${DataChannelKind[kind]}' closed unexpectedly`,
|
|
1090
|
+
this.logContext,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1008
1095
|
private handleBufferedAmountLow = (channelKind: DataChannelKind) => {
|
|
1009
1096
|
this.updateAndEmitDCBufferStatus(channelKind);
|
|
1010
1097
|
};
|
|
@@ -1023,7 +1110,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1023
1110
|
} else {
|
|
1024
1111
|
throw new UnexpectedConnectionState('Required webRTC APIs not supported on this device');
|
|
1025
1112
|
}
|
|
1026
|
-
this.
|
|
1113
|
+
this.setupFrameMetadataSender(sender, opts);
|
|
1027
1114
|
return sender;
|
|
1028
1115
|
}
|
|
1029
1116
|
|
|
@@ -1043,50 +1130,55 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1043
1130
|
throw new UnexpectedConnectionState('Cannot stream on this device');
|
|
1044
1131
|
}
|
|
1045
1132
|
if (sender) {
|
|
1046
|
-
this.
|
|
1133
|
+
this.setupFrameMetadataSender(sender, opts);
|
|
1047
1134
|
}
|
|
1048
1135
|
return sender;
|
|
1049
1136
|
}
|
|
1050
1137
|
|
|
1051
|
-
private
|
|
1052
|
-
|
|
1138
|
+
private get frameMetadataWorker(): Worker | undefined {
|
|
1139
|
+
return (this.options.frameMetadata ?? this.options.packetTrailer)?.worker;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private setupFrameMetadataSender(sender: RTCRtpSender, opts: TrackPublishOptions = {}) {
|
|
1143
|
+
const worker = this.frameMetadataWorker;
|
|
1144
|
+
if (!worker || this.signalOpts?.e2eeEnabled) {
|
|
1053
1145
|
return;
|
|
1054
1146
|
}
|
|
1055
1147
|
|
|
1056
|
-
const
|
|
1057
|
-
const
|
|
1148
|
+
const frameMetadata = opts.frameMetadata ?? opts.packetTrailer;
|
|
1149
|
+
const hasMetadata = hasFrameMetadataPublishOptions(frameMetadata);
|
|
1058
1150
|
|
|
1059
|
-
if (
|
|
1060
|
-
if (
|
|
1151
|
+
if (shouldUseFrameMetadataScriptTransform()) {
|
|
1152
|
+
if (hasMetadata) {
|
|
1061
1153
|
// @ts-ignore
|
|
1062
|
-
sender.transform = new RTCRtpScriptTransform(
|
|
1154
|
+
sender.transform = new RTCRtpScriptTransform(worker, {
|
|
1063
1155
|
kind: 'encode',
|
|
1064
|
-
packetTrailer,
|
|
1156
|
+
packetTrailer: frameMetadata,
|
|
1065
1157
|
});
|
|
1066
1158
|
}
|
|
1067
1159
|
return;
|
|
1068
1160
|
}
|
|
1069
1161
|
|
|
1070
1162
|
if (
|
|
1071
|
-
!
|
|
1163
|
+
!isFrameMetadataSupported(this.options.frameMetadata ?? this.options.packetTrailer) ||
|
|
1072
1164
|
!('createEncodedStreams' in sender)
|
|
1073
1165
|
) {
|
|
1074
|
-
if (
|
|
1075
|
-
this.log.warn('
|
|
1166
|
+
if (hasMetadata) {
|
|
1167
|
+
this.log.warn('frame metadata transform not supported; skipping write', this.logContext);
|
|
1076
1168
|
}
|
|
1077
1169
|
return;
|
|
1078
1170
|
}
|
|
1079
1171
|
|
|
1080
1172
|
// @ts-ignore
|
|
1081
1173
|
const { readable, writable } = sender.createEncodedStreams();
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1174
|
+
if (hasMetadata) {
|
|
1175
|
+
worker.postMessage(
|
|
1084
1176
|
{
|
|
1085
1177
|
kind: 'encode',
|
|
1086
1178
|
data: {
|
|
1087
1179
|
readableStream: readable,
|
|
1088
1180
|
writableStream: writable,
|
|
1089
|
-
packetTrailer,
|
|
1181
|
+
packetTrailer: frameMetadata,
|
|
1090
1182
|
},
|
|
1091
1183
|
},
|
|
1092
1184
|
[readable, writable],
|
|
@@ -1506,6 +1598,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1506
1598
|
|
|
1507
1599
|
const msg = packet.toBinary() as Uint8Array<ArrayBuffer>;
|
|
1508
1600
|
|
|
1601
|
+
// Clamp to the SDK default - libwebrtc advertises larger (~256 KiB)
|
|
1602
|
+
// than LiveKit/pion can deliver end-to-end (~64 KiB), so we trust
|
|
1603
|
+
// the answer up untilthe built in ceiling.
|
|
1604
|
+
const maxPublisherMessageSizeBytes = Math.min(
|
|
1605
|
+
this.pcManager?.getMaxPublisherMessageSize() ?? DEFAULT_MAX_MESSAGE_SIZE,
|
|
1606
|
+
DEFAULT_MAX_MESSAGE_SIZE,
|
|
1607
|
+
);
|
|
1608
|
+
if (
|
|
1609
|
+
typeof maxPublisherMessageSizeBytes !== 'undefined' &&
|
|
1610
|
+
maxPublisherMessageSizeBytes !== 0 /* 0 means "no limit" */ &&
|
|
1611
|
+
msg.byteLength > maxPublisherMessageSizeBytes
|
|
1612
|
+
) {
|
|
1613
|
+
throw new PublishDataError(
|
|
1614
|
+
`cannot publish data packet larger than ${maxPublisherMessageSizeBytes} bytes (got ${msg.byteLength})`,
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1509
1618
|
switch (kind) {
|
|
1510
1619
|
case DataChannelKind.LOSSY:
|
|
1511
1620
|
case DataChannelKind.DATA_TRACK_LOSSY:
|
|
@@ -1746,20 +1855,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1746
1855
|
}
|
|
1747
1856
|
this.on(EngineEvent.Closing, handleClosed);
|
|
1748
1857
|
this.on(EngineEvent.Restarting, handleClosed);
|
|
1749
|
-
|
|
1750
|
-
this.pcManager.publisher.once(
|
|
1751
|
-
PCEvents.RTPVideoPayloadTypes,
|
|
1752
|
-
(rtpTypes: MediaAttributes['rtp']) => {
|
|
1753
|
-
const rtpMap = new Map<number, VideoCodec>();
|
|
1754
|
-
rtpTypes.forEach((rtp) => {
|
|
1755
|
-
const codec = rtp.codec.toLowerCase();
|
|
1756
|
-
if (isVideoCodec(codec)) {
|
|
1757
|
-
rtpMap.set(rtp.payload, codec);
|
|
1758
|
-
}
|
|
1759
|
-
});
|
|
1760
|
-
this.emit(EngineEvent.RTPVideoMapUpdate, rtpMap);
|
|
1761
|
-
},
|
|
1762
|
-
);
|
|
1858
|
+
this.pcManager.publisher.off(PCEvents.RTPVideoPayloadTypes, this.onRtpMapAvailable);
|
|
1859
|
+
this.pcManager.publisher.once(PCEvents.RTPVideoPayloadTypes, this.onRtpMapAvailable);
|
|
1763
1860
|
|
|
1764
1861
|
try {
|
|
1765
1862
|
await this.pcManager.negotiate(abortController);
|
|
@@ -1906,6 +2003,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1906
2003
|
this.shouldFailOnV1Path = true;
|
|
1907
2004
|
}
|
|
1908
2005
|
|
|
2006
|
+
private onRtpMapAvailable = (rtpTypes: MediaAttributes['rtp']) => {
|
|
2007
|
+
const rtpMap = new Map<number, VideoCodec>();
|
|
2008
|
+
rtpTypes.forEach((rtp) => {
|
|
2009
|
+
const codec = rtp.codec.toLowerCase();
|
|
2010
|
+
if (isVideoCodec(codec)) {
|
|
2011
|
+
rtpMap.set(rtp.payload, codec);
|
|
2012
|
+
}
|
|
2013
|
+
});
|
|
2014
|
+
this.emit(EngineEvent.RTPVideoMapUpdate, rtpMap);
|
|
2015
|
+
};
|
|
2016
|
+
|
|
1909
2017
|
private dataChannelsInfo(): DataChannelInfo[] {
|
|
1910
2018
|
const infos: DataChannelInfo[] = [];
|
|
1911
2019
|
const getInfo = (dc: RTCDataChannel | undefined, target: SignalTarget) => {
|
package/src/room/Room.ts
CHANGED
|
@@ -37,6 +37,8 @@ import type TypedEmitter from 'typed-emitter';
|
|
|
37
37
|
import { ensureTrailingSlash } from '../api/utils';
|
|
38
38
|
import { EncryptionEvent } from '../e2ee';
|
|
39
39
|
import { type BaseE2EEManager, E2EEManager } from '../e2ee/E2eeManager';
|
|
40
|
+
import { FrameMetadataManager } from '../frameMetadata/FrameMetadataManager';
|
|
41
|
+
import { isFrameMetadataSupported } from '../frameMetadata/utils';
|
|
40
42
|
import log, { LoggerNames, getLogger } from '../logger';
|
|
41
43
|
import type {
|
|
42
44
|
InternalRoomConnectOptions,
|
|
@@ -44,8 +46,6 @@ import type {
|
|
|
44
46
|
RoomConnectOptions,
|
|
45
47
|
RoomOptions,
|
|
46
48
|
} from '../options';
|
|
47
|
-
import { PacketTrailerManager } from '../packetTrailer/PacketTrailerManager';
|
|
48
|
-
import { isPacketTrailerSupported } from '../packetTrailer/utils';
|
|
49
49
|
import TypedPromise from '../utils/TypedPromise';
|
|
50
50
|
import { getBrowser } from '../utils/browserParser';
|
|
51
51
|
import { CLIENT_PROTOCOL_DEFAULT } from '../version';
|
|
@@ -198,7 +198,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
198
198
|
|
|
199
199
|
private e2eeManager: BaseE2EEManager | undefined;
|
|
200
200
|
|
|
201
|
-
private
|
|
201
|
+
private frameMetadataManager: FrameMetadataManager | undefined;
|
|
202
202
|
|
|
203
203
|
private e2eeStateMutex: Mutex = new Mutex();
|
|
204
204
|
|
|
@@ -343,7 +343,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
343
343
|
this.rpcServerManager,
|
|
344
344
|
);
|
|
345
345
|
|
|
346
|
-
this.
|
|
346
|
+
this.setupFrameMetadata();
|
|
347
347
|
|
|
348
348
|
if (this.options.e2ee || this.options.encryption) {
|
|
349
349
|
this.setupE2EE();
|
|
@@ -514,11 +514,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
514
514
|
}
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
-
private
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
this.
|
|
521
|
-
this.packetTrailerManager.setup(this);
|
|
517
|
+
private setupFrameMetadata() {
|
|
518
|
+
const opts = this.options.frameMetadata ?? this.options.packetTrailer;
|
|
519
|
+
this.frameMetadataManager = new FrameMetadataManager(opts);
|
|
520
|
+
this.frameMetadataManager.setup(this);
|
|
522
521
|
}
|
|
523
522
|
|
|
524
523
|
private get logContext() {
|
|
@@ -972,7 +971,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
972
971
|
adaptiveStream:
|
|
973
972
|
typeof roomOptions.adaptiveStream === 'object' ? true : roomOptions.adaptiveStream,
|
|
974
973
|
clientInfoCapabilities:
|
|
975
|
-
|
|
974
|
+
isFrameMetadataSupported(roomOptions.frameMetadata ?? roomOptions.packetTrailer) ||
|
|
975
|
+
!!this.e2eeManager
|
|
976
976
|
? [ClientInfo_Capability.CAP_PACKET_TRAILER]
|
|
977
977
|
: undefined,
|
|
978
978
|
maxRetries: connectOptions.maxRetries,
|
|
@@ -5,18 +5,18 @@ import { Track } from '../track/Track';
|
|
|
5
5
|
import type { TrackPublishOptions } from '../track/options';
|
|
6
6
|
import LocalParticipant from './LocalParticipant';
|
|
7
7
|
|
|
8
|
-
type
|
|
9
|
-
|
|
8
|
+
type FrameMetadataTestParticipant = {
|
|
9
|
+
canPublishFrameMetadata: () => boolean;
|
|
10
10
|
log: { warn: ReturnType<typeof vi.fn> };
|
|
11
|
-
|
|
11
|
+
normalizeRequestedFrameMetadataOptions: (
|
|
12
12
|
track: LocalTrack,
|
|
13
13
|
opts: TrackPublishOptions,
|
|
14
14
|
) => PacketTrailerFeature[];
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
function makeParticipant(
|
|
18
|
-
const participant = Object.create(LocalParticipant.prototype) as
|
|
19
|
-
participant.
|
|
17
|
+
function makeParticipant(canPublishFrameMetadata: boolean) {
|
|
18
|
+
const participant = Object.create(LocalParticipant.prototype) as FrameMetadataTestParticipant;
|
|
19
|
+
participant.canPublishFrameMetadata = () => canPublishFrameMetadata;
|
|
20
20
|
participant.log = { warn: vi.fn() };
|
|
21
21
|
return participant;
|
|
22
22
|
}
|
|
@@ -35,12 +35,12 @@ function makeTrack(kind: Track.Kind) {
|
|
|
35
35
|
} as unknown as LocalTrack;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
describe('LocalParticipant
|
|
39
|
-
it('normalizes requested video
|
|
38
|
+
describe('LocalParticipant frame metadata publish options', () => {
|
|
39
|
+
it('normalizes requested video frame metadata options to advertised features', () => {
|
|
40
40
|
const participant = makeParticipant(true);
|
|
41
|
-
const opts: TrackPublishOptions = {
|
|
41
|
+
const opts: TrackPublishOptions = { frameMetadata: { timestamp: true, frameId: true } };
|
|
42
42
|
|
|
43
|
-
const features = participant.
|
|
43
|
+
const features = participant.normalizeRequestedFrameMetadataOptions(
|
|
44
44
|
makeTrack(Track.Kind.Video),
|
|
45
45
|
opts,
|
|
46
46
|
);
|
|
@@ -49,33 +49,33 @@ describe('LocalParticipant packet trailer publish options', () => {
|
|
|
49
49
|
PacketTrailerFeature.PTF_USER_TIMESTAMP,
|
|
50
50
|
PacketTrailerFeature.PTF_FRAME_ID,
|
|
51
51
|
]);
|
|
52
|
-
expect(opts.
|
|
52
|
+
expect(opts.frameMetadata).toEqual({ timestamp: true, frameId: true });
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
it('clears
|
|
55
|
+
it('clears frame metadata options for non-video tracks', () => {
|
|
56
56
|
const participant = makeParticipant(true);
|
|
57
|
-
const opts: TrackPublishOptions = {
|
|
57
|
+
const opts: TrackPublishOptions = { frameMetadata: { timestamp: true } };
|
|
58
58
|
|
|
59
|
-
const features = participant.
|
|
59
|
+
const features = participant.normalizeRequestedFrameMetadataOptions(
|
|
60
60
|
makeTrack(Track.Kind.Audio),
|
|
61
61
|
opts,
|
|
62
62
|
);
|
|
63
63
|
|
|
64
64
|
expect(features).toEqual([]);
|
|
65
|
-
expect(opts.
|
|
65
|
+
expect(opts.frameMetadata).toBeUndefined();
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
it('clears
|
|
68
|
+
it('clears frame metadata options when publishing frame metadata is unsupported', () => {
|
|
69
69
|
const participant = makeParticipant(false);
|
|
70
|
-
const opts: TrackPublishOptions = {
|
|
70
|
+
const opts: TrackPublishOptions = { frameMetadata: { frameId: true } };
|
|
71
71
|
|
|
72
|
-
const features = participant.
|
|
72
|
+
const features = participant.normalizeRequestedFrameMetadataOptions(
|
|
73
73
|
makeTrack(Track.Kind.Video),
|
|
74
74
|
opts,
|
|
75
75
|
);
|
|
76
76
|
|
|
77
77
|
expect(features).toEqual([]);
|
|
78
|
-
expect(opts.
|
|
78
|
+
expect(opts.frameMetadata).toBeUndefined();
|
|
79
79
|
expect(participant.log.warn).toHaveBeenCalledOnce();
|
|
80
80
|
});
|
|
81
81
|
});
|
|
@@ -22,13 +22,13 @@ import {
|
|
|
22
22
|
protoInt64,
|
|
23
23
|
} from '@livekit/protocol';
|
|
24
24
|
import { SignalConnectionState } from '../../api/SignalClient';
|
|
25
|
-
import type { InternalRoomOptions } from '../../options';
|
|
26
25
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} from '../../
|
|
26
|
+
getFrameMetadataFeatures,
|
|
27
|
+
getFrameMetadataPublishOptions,
|
|
28
|
+
hasFrameMetadataPublishOptions,
|
|
29
|
+
isFrameMetadataSupported,
|
|
30
|
+
} from '../../frameMetadata/utils';
|
|
31
|
+
import type { InternalRoomOptions } from '../../options';
|
|
32
32
|
import TypedPromise from '../../utils/TypedPromise';
|
|
33
33
|
import { PCTransportState } from '../PCTransportManager';
|
|
34
34
|
import type RTCEngine from '../RTCEngine';
|
|
@@ -1085,7 +1085,7 @@ export default class LocalParticipant extends Participant {
|
|
|
1085
1085
|
audioFeatures.push(AudioTrackFeature.TF_PRECONNECT_BUFFER);
|
|
1086
1086
|
}
|
|
1087
1087
|
const packetTrailerFeatures: PacketTrailerFeature[] =
|
|
1088
|
-
this.
|
|
1088
|
+
this.normalizeRequestedFrameMetadataOptions(track, opts);
|
|
1089
1089
|
|
|
1090
1090
|
// create track publication from track
|
|
1091
1091
|
const req = new AddTrackRequest({
|
|
@@ -1416,31 +1416,36 @@ export default class LocalParticipant extends Participant {
|
|
|
1416
1416
|
return publication;
|
|
1417
1417
|
}
|
|
1418
1418
|
|
|
1419
|
-
private
|
|
1419
|
+
private canPublishFrameMetadata() {
|
|
1420
1420
|
return !!(
|
|
1421
1421
|
this.roomOptions.e2ee ||
|
|
1422
1422
|
this.roomOptions.encryption ||
|
|
1423
|
-
|
|
1423
|
+
isFrameMetadataSupported(this.roomOptions.frameMetadata ?? this.roomOptions.packetTrailer)
|
|
1424
1424
|
);
|
|
1425
1425
|
}
|
|
1426
1426
|
|
|
1427
|
-
private
|
|
1428
|
-
|
|
1427
|
+
private normalizeRequestedFrameMetadataOptions(track: LocalTrack, opts: TrackPublishOptions) {
|
|
1428
|
+
const fmOpts = opts.frameMetadata ?? opts.packetTrailer;
|
|
1429
|
+
if (track.kind !== Track.Kind.Video || !hasFrameMetadataPublishOptions(fmOpts)) {
|
|
1430
|
+
opts.frameMetadata = undefined;
|
|
1429
1431
|
opts.packetTrailer = undefined;
|
|
1430
1432
|
return [];
|
|
1431
1433
|
}
|
|
1432
1434
|
|
|
1433
|
-
if (!this.
|
|
1434
|
-
this.log.warn('
|
|
1435
|
+
if (!this.canPublishFrameMetadata()) {
|
|
1436
|
+
this.log.warn('frame metadata transform not supported; not advertising features', {
|
|
1435
1437
|
...this.logContext,
|
|
1436
1438
|
...getLogContextFromTrack(track),
|
|
1437
1439
|
});
|
|
1440
|
+
opts.frameMetadata = undefined;
|
|
1438
1441
|
opts.packetTrailer = undefined;
|
|
1439
1442
|
return [];
|
|
1440
1443
|
}
|
|
1441
1444
|
|
|
1442
|
-
const features =
|
|
1443
|
-
|
|
1445
|
+
const features = getFrameMetadataFeatures(fmOpts);
|
|
1446
|
+
const normalized = getFrameMetadataPublishOptions(features);
|
|
1447
|
+
opts.frameMetadata = normalized;
|
|
1448
|
+
opts.packetTrailer = normalized;
|
|
1444
1449
|
return features;
|
|
1445
1450
|
}
|
|
1446
1451
|
|
|
@@ -1496,7 +1501,7 @@ export default class LocalParticipant extends Participant {
|
|
|
1496
1501
|
if (!simulcastTrack) {
|
|
1497
1502
|
return;
|
|
1498
1503
|
}
|
|
1499
|
-
const packetTrailerFeatures = this.
|
|
1504
|
+
const packetTrailerFeatures = this.normalizeRequestedFrameMetadataOptions(track, opts);
|
|
1500
1505
|
|
|
1501
1506
|
const req = new AddTrackRequest({
|
|
1502
1507
|
cid: simulcastTrack.mediaStreamTrack.id,
|
|
@@ -174,6 +174,12 @@ describe('customSimulcastLayers', () => {
|
|
|
174
174
|
expect(sortedPresets[1].encoding.maxFramerate).toBe(15);
|
|
175
175
|
expect(sortedPresets[2].encoding.maxFramerate).toBe(20);
|
|
176
176
|
});
|
|
177
|
+
it('does not mutate the passed-in array', () => {
|
|
178
|
+
const presets = [VideoPresets.h1440, VideoPresets.h360, VideoPresets.h1080, VideoPresets.h90];
|
|
179
|
+
const original = [...presets];
|
|
180
|
+
sortPresets(presets);
|
|
181
|
+
expect(presets).toEqual(original);
|
|
182
|
+
});
|
|
177
183
|
});
|
|
178
184
|
|
|
179
185
|
describe('screenShareSimulcastDefaults', () => {
|
|
@@ -419,7 +419,10 @@ function encodingsFromPresets(
|
|
|
419
419
|
/** @internal */
|
|
420
420
|
export function sortPresets(presets: Array<VideoPreset> | undefined) {
|
|
421
421
|
if (!presets) return;
|
|
422
|
-
|
|
422
|
+
// Sort a copy so we don't mutate the caller's array in place. Mutating the
|
|
423
|
+
// passed-in simulcast layers can cause consumers that compare options by value
|
|
424
|
+
// (e.g. components-react's useLiveKitRoom) to detect a spurious change.
|
|
425
|
+
return presets.slice().sort((a, b) => {
|
|
423
426
|
const { encoding: aEnc } = a;
|
|
424
427
|
const { encoding: bEnc } = b;
|
|
425
428
|
|