livekit-client 2.22.2 → 2.22.3

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.
Files changed (68) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +486 -437
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +398 -100
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +8 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  14. package/dist/src/api/utils.d.ts +1 -0
  15. package/dist/src/api/utils.d.ts.map +1 -1
  16. package/dist/src/e2ee/E2eeManager.d.ts +26 -0
  17. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  18. package/dist/src/e2ee/types.d.ts +15 -1
  19. package/dist/src/e2ee/types.d.ts.map +1 -1
  20. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  21. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  22. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
  23. package/dist/src/e2ee/worker/FrameCryptor.d.ts +1 -18
  24. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  25. package/dist/src/logger.d.ts +4 -0
  26. package/dist/src/logger.d.ts.map +1 -1
  27. package/dist/src/room/RTCEngine.d.ts +1 -0
  28. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  29. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  30. package/dist/src/room/participant/publishUtils.d.ts +16 -0
  31. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  32. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  33. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  34. package/dist/src/room/track/options.d.ts +1 -1
  35. package/dist/src/room/utils.d.ts +34 -0
  36. package/dist/src/room/utils.d.ts.map +1 -1
  37. package/dist/ts4.2/api/utils.d.ts +1 -0
  38. package/dist/ts4.2/e2ee/E2eeManager.d.ts +26 -0
  39. package/dist/ts4.2/e2ee/types.d.ts +15 -1
  40. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  41. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +1 -18
  42. package/dist/ts4.2/logger.d.ts +4 -0
  43. package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
  44. package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
  45. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  46. package/dist/ts4.2/room/track/options.d.ts +1 -1
  47. package/dist/ts4.2/room/utils.d.ts +34 -0
  48. package/package.json +1 -1
  49. package/src/api/WebSocketStream.ts +3 -8
  50. package/src/api/utils.ts +10 -0
  51. package/src/e2ee/E2eeManager.test.ts +196 -0
  52. package/src/e2ee/E2eeManager.ts +114 -19
  53. package/src/e2ee/types.ts +19 -1
  54. package/src/e2ee/worker/DataCryptor.ts +2 -1
  55. package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
  56. package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
  57. package/src/e2ee/worker/FrameCryptor.ts +20 -70
  58. package/src/e2ee/worker/e2ee.worker.ts +34 -11
  59. package/src/logger.ts +22 -0
  60. package/src/room/RTCEngine.ts +28 -7
  61. package/src/room/Room.ts +1 -1
  62. package/src/room/participant/LocalParticipant.ts +30 -14
  63. package/src/room/participant/publishUtils.test.ts +133 -0
  64. package/src/room/participant/publishUtils.ts +54 -19
  65. package/src/room/track/LocalVideoTrack.ts +15 -5
  66. package/src/room/track/options.ts +1 -1
  67. package/src/room/utils.test.ts +87 -0
  68. package/src/room/utils.ts +59 -0
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ErrorRateLimiter } from './ErrorRateLimiter';
3
+
4
+ describe('ErrorRateLimiter', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(1_000_000);
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ it('emits first call, throttles the immediate next, then allows after throttle window', () => {
15
+ const l = new ErrorRateLimiter(1000, 60_000, 5);
16
+ expect(l.shouldEmit('k')).toBe(true);
17
+ vi.setSystemTime(1_000_500);
18
+ expect(l.shouldEmit('k')).toBe(false);
19
+ vi.setSystemTime(1_001_600);
20
+ expect(l.shouldEmit('k')).toBe(true);
21
+ });
22
+
23
+ it('caps at maxPerWindow and invokes onSuppress once', () => {
24
+ const l = new ErrorRateLimiter(0, 60_000, 3);
25
+ const onSuppress = vi.fn();
26
+ // one free emit on window reset (count stays 0), then increments to 3
27
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
28
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
29
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
30
+ expect(l.shouldEmit('k', onSuppress)).toBe(true);
31
+ // now count == 3 == max → suppressed, callback fires once
32
+ expect(l.shouldEmit('k', onSuppress)).toBe(false);
33
+ expect(l.shouldEmit('k', onSuppress)).toBe(false);
34
+ expect(onSuppress).toHaveBeenCalledTimes(1);
35
+ });
36
+
37
+ it('resets count after windowMs', () => {
38
+ const l = new ErrorRateLimiter(0, 1000, 2);
39
+ l.shouldEmit('k');
40
+ l.shouldEmit('k');
41
+ l.shouldEmit('k');
42
+ expect(l.shouldEmit('k')).toBe(false);
43
+ vi.setSystemTime(1_003_000);
44
+ expect(l.shouldEmit('k')).toBe(true);
45
+ });
46
+
47
+ it('tracks keys independently', () => {
48
+ const l = new ErrorRateLimiter(1000);
49
+ expect(l.shouldEmit('a')).toBe(true);
50
+ expect(l.shouldEmit('b')).toBe(true);
51
+ expect(l.shouldEmit('a')).toBe(false);
52
+ });
53
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-key rate limiter for repeated errors. Prevents log/emit floods and the
3
+ * unbounded map growth that a per-event log would cause when a broken key
4
+ * keeps producing failures.
5
+ */
6
+ export class ErrorRateLimiter {
7
+ private lastAt: Map<string, number> = new Map();
8
+
9
+ private counts: Map<string, number> = new Map();
10
+
11
+ constructor(
12
+ private readonly throttleMs: number = 1000,
13
+ private readonly windowMs: number = 60_000,
14
+ private readonly maxPerWindow: number = 5,
15
+ ) {}
16
+
17
+ reset() {
18
+ this.lastAt.clear();
19
+ this.counts.clear();
20
+ }
21
+
22
+ countFor(key: string): number {
23
+ return this.counts.get(key) ?? 0;
24
+ }
25
+
26
+ /**
27
+ * Returns true if the caller should emit for this key. Invokes `onSuppress`
28
+ * exactly once per window when the per-window limit is first crossed.
29
+ */
30
+ shouldEmit(key: string, onSuppress?: () => void): boolean {
31
+ const now = Date.now();
32
+ const last = this.lastAt.get(key) ?? 0;
33
+ const count = this.counts.get(key) ?? 0;
34
+
35
+ if (now - last > this.windowMs) {
36
+ this.counts.set(key, 0);
37
+ this.lastAt.set(key, now);
38
+ return true;
39
+ }
40
+ if (now - last < this.throttleMs) return false;
41
+ if (count >= this.maxPerWindow) {
42
+ if (count === this.maxPerWindow) {
43
+ onSuppress?.();
44
+ this.counts.set(key, count + 1);
45
+ }
46
+ return false;
47
+ }
48
+ this.lastAt.set(key, now);
49
+ this.counts.set(key, count + 1);
50
+ return true;
51
+ }
52
+ }
@@ -1,6 +1,7 @@
1
1
  // TODO code inspired by https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption/js/worker.js
2
2
  import { EventEmitter } from 'events';
3
3
  import type TypedEventEmitter from 'typed-emitter';
4
+ import { getErrorDescription } from '../../api/utils';
4
5
  import {
5
6
  appendPacketTrailerToEncodedFrame,
6
7
  processPacketTrailer,
@@ -22,6 +23,7 @@ import type {
22
23
  RatchetResult,
23
24
  } from '../types';
24
25
  import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
26
+ import { ErrorRateLimiter } from './ErrorRateLimiter';
25
27
  import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
26
28
  import { processNALUsForEncryption } from './naluUtils';
27
29
  import { identifySifPayload } from './sifPayload';
@@ -111,18 +113,7 @@ export class FrameCryptor extends BaseFrameCryptor {
111
113
 
112
114
  private frameMetadataFrameId = 0;
113
115
 
114
- /**
115
- * Throttling mechanism for decryption errors to prevent memory leaks
116
- */
117
- private lastErrorTimestamp: Map<string, number> = new Map();
118
-
119
- private errorCounts: Map<string, number> = new Map();
120
-
121
- private readonly ERROR_THROTTLE_MS = 1000; // Emit error at most once per second
122
-
123
- private readonly MAX_ERRORS_PER_MINUTE = 5; // Maximum errors to emit per minute per key
124
-
125
- private readonly ERROR_WINDOW_MS = 60000; // 1 minute window
116
+ private errorLimiter = new ErrorRateLimiter();
126
117
 
127
118
  private undecryptedTrackTimeout?: ReturnType<typeof setTimeout>;
128
119
 
@@ -196,8 +187,7 @@ export class FrameCryptor extends BaseFrameCryptor {
196
187
  clearTimeout(this.undecryptedTrackTimeout);
197
188
  this.undecryptedTrackTimeout = undefined;
198
189
  this.participantIdentity = undefined;
199
- this.lastErrorTimestamp = new Map();
200
- this.errorCounts = new Map();
190
+ this.errorLimiter.reset();
201
191
  }
202
192
 
203
193
  isEnabled() {
@@ -421,64 +411,24 @@ export class FrameCryptor extends BaseFrameCryptor {
421
411
  this.sifTrailer = trailer;
422
412
  }
423
413
 
424
- /**
425
- * Checks if we should emit an error based on throttling rules to prevent memory leaks
426
- * @param errorKey - unique key identifying the error context
427
- * @returns true if the error should be emitted, false otherwise
428
- */
429
- private shouldEmitError(errorKey: string): boolean {
430
- const now = Date.now();
431
- const lastErrorTime = this.lastErrorTimestamp.get(errorKey) ?? 0;
432
- const errorCount = this.errorCounts.get(errorKey) ?? 0;
433
-
434
- // Reset count if we're in a new time window
435
- if (now - lastErrorTime > this.ERROR_WINDOW_MS) {
436
- this.errorCounts.set(errorKey, 0);
437
- this.lastErrorTimestamp.set(errorKey, now);
438
- return true;
439
- }
440
-
441
- // Check if we've exceeded the throttle time
442
- if (now - lastErrorTime < this.ERROR_THROTTLE_MS) {
443
- return false;
444
- }
445
-
446
- // Check if we've exceeded the max errors per window
447
- if (errorCount >= this.MAX_ERRORS_PER_MINUTE) {
448
- // Only log a warning once when hitting the limit
449
- if (errorCount === this.MAX_ERRORS_PER_MINUTE) {
450
- workerLogger.warn(`Suppressing further decryption errors for ${this.participantIdentity}`, {
451
- ...this.logContext,
452
- errorKey,
453
- });
454
- this.errorCounts.set(errorKey, errorCount + 1);
455
- }
456
- return false;
457
- }
458
-
459
- // Update tracking
460
- this.lastErrorTimestamp.set(errorKey, now);
461
- this.errorCounts.set(errorKey, errorCount + 1);
462
- return true;
463
- }
464
-
465
- /**
466
- * Emits a throttled error to prevent memory leaks from repeated decryption failures
467
- * @param error - the CryptorError to emit
468
- */
469
414
  private emitThrottledError(error: CryptorError) {
470
415
  const errorKey = `${this.participantIdentity}-${error.reason}-decrypt`;
416
+ const emit = this.errorLimiter.shouldEmit(errorKey, () => {
417
+ workerLogger.warn(`Suppressing further decryption errors for ${this.participantIdentity}`, {
418
+ ...this.logContext,
419
+ errorKey,
420
+ });
421
+ });
422
+ if (!emit) return;
471
423
 
472
- if (this.shouldEmitError(errorKey)) {
473
- const errorCount = this.errorCounts.get(errorKey) ?? 0;
474
- if (errorCount > 1) {
475
- workerLogger.debug(`Decryption error (${errorCount} occurrences in window)`, {
476
- ...this.logContext,
477
- reason: CryptorErrorReason[error.reason],
478
- });
479
- }
480
- this.emit(CryptorEvent.Error, error);
424
+ const count = this.errorLimiter.countFor(errorKey);
425
+ if (count > 1) {
426
+ workerLogger.debug(`Decryption error (${count} occurrences in window)`, {
427
+ ...this.logContext,
428
+ reason: CryptorErrorReason[error.reason],
429
+ });
481
430
  }
431
+ this.emit(CryptorEvent.Error, error);
482
432
  }
483
433
 
484
434
  /**
@@ -587,7 +537,7 @@ export class FrameCryptor extends BaseFrameCryptor {
587
537
  return controller.enqueue(encodedFrame);
588
538
  } catch (e: any) {
589
539
  // TODO: surface this to the app.
590
- workerLogger.error(e);
540
+ workerLogger.error(`error while encrypting`, { ...this.logContext, error: e });
591
541
  }
592
542
  } else {
593
543
  workerLogger.debug('failed to encrypt, emitting error', this.logContext);
@@ -858,7 +808,7 @@ export class FrameCryptor extends BaseFrameCryptor {
858
808
  }
859
809
  } else {
860
810
  throw new CryptorError(
861
- `Decryption failed: ${error.message}`,
811
+ `Decryption failed: ${getErrorDescription(error, 'decryption')}`,
862
812
  CryptorErrorReason.InvalidKey,
863
813
  this.participantIdentity,
864
814
  );
@@ -18,6 +18,7 @@ import type {
18
18
  ScriptTransformOptions,
19
19
  } from '../types';
20
20
  import { DataCryptor } from './DataCryptor';
21
+ import { ErrorRateLimiter } from './ErrorRateLimiter';
21
22
  import { FrameCryptor, encryptionEnabledMap } from './FrameCryptor';
22
23
  import { ParticipantKeyHandler } from './ParticipantKeyHandler';
23
24
 
@@ -36,8 +37,21 @@ let keyProviderOptions: KeyProviderOptions = KEY_PROVIDER_DEFAULTS;
36
37
 
37
38
  let rtpMap: Map<number, VideoCodec> = new Map();
38
39
 
40
+ const dataDecryptErrorLimiter = new ErrorRateLimiter();
41
+
39
42
  workerLogger.setDefaultLevel('info');
40
43
 
44
+ // Forward worker log calls to the main thread so they reach any
45
+ // setLogExtension consumer installed there. The main-thread workerLogger
46
+ // re-emits them, which invokes both the console and the extension.
47
+ workerLogger.methodFactory = (methodName) => (msg, context) => {
48
+ postMessage({
49
+ kind: 'log',
50
+ data: { level: methodName, msg, context },
51
+ });
52
+ };
53
+ workerLogger.setLevel(workerLogger.getLevel());
54
+
41
55
  onmessage = (ev) => {
42
56
  messageQueue.run(async () => {
43
57
  const { kind, data }: E2EEWorkerMessage = ev.data;
@@ -45,7 +59,7 @@ onmessage = (ev) => {
45
59
  switch (kind) {
46
60
  case 'init':
47
61
  workerLogger.setLevel(data.loglevel);
48
- workerLogger.info('worker initialized');
62
+ workerLogger.info('e2ee worker initialized');
49
63
  keyProviderOptions = data.keyProviderOptions;
50
64
  useSharedKey = !!data.keyProviderOptions.sharedKey;
51
65
  // acknowledge init successful
@@ -55,6 +69,9 @@ onmessage = (ev) => {
55
69
  };
56
70
  postMessage(ackMsg);
57
71
  break;
72
+ case 'setLogLevel':
73
+ workerLogger.setLevel(data.level);
74
+ break;
58
75
  case 'enable':
59
76
  setEncryptionEnabled(data.enabled, data.participantIdentity);
60
77
  workerLogger.info(
@@ -96,11 +113,6 @@ onmessage = (ev) => {
96
113
  data.payload,
97
114
  getParticipantKeyHandler(data.participantIdentity),
98
115
  );
99
- console.log('encrypted payload', {
100
- original: data.payload,
101
- encrypted: encryptedPayload,
102
- iv,
103
- });
104
116
  postMessage({
105
117
  kind: 'encryptDataResponse',
106
118
  data: {
@@ -125,12 +137,23 @@ onmessage = (ev) => {
125
137
  data: { payload: decryptedPayload, uuid: data.uuid },
126
138
  } satisfies DecryptDataResponseMessage);
127
139
  } catch (error) {
128
- // Send error back to main thread with uuid so it can reject the corresponding promise
129
- workerLogger.error('DataCryptor decryption failed', {
130
- error,
131
- participantIdentity: data.participantIdentity,
132
- uuid: data.uuid,
140
+ // Send error back to main thread with uuid so it can reject the corresponding promise.
141
+ // The error response must always be posted so the awaiting future resolves; only the
142
+ // log is throttled to avoid flooding when a broken key keeps producing failures.
143
+ const errorKey = `${data.participantIdentity}-datadecrypt`;
144
+ const shouldLog = dataDecryptErrorLimiter.shouldEmit(errorKey, () => {
145
+ workerLogger.warn(
146
+ `Suppressing further data decryption errors for ${data.participantIdentity}`,
147
+ { errorKey },
148
+ );
133
149
  });
150
+ if (shouldLog) {
151
+ workerLogger.error('DataCryptor decryption failed', {
152
+ error,
153
+ participantIdentity: data.participantIdentity,
154
+ uuid: data.uuid,
155
+ });
156
+ }
134
157
  postMessage({
135
158
  kind: 'error',
136
159
  data: {
package/src/logger.ts CHANGED
@@ -131,3 +131,25 @@ export function setLogExtension(extension: LogExtension, logger?: StructuredLogg
131
131
  }
132
132
 
133
133
  export const workerLogger = log.getLogger(LoggerNames.E2EE) as StructuredLogger;
134
+
135
+ const workerLogLevelListeners = new Set<(level: LogLevel) => void>();
136
+
137
+ const originalWorkerSetLevel = workerLogger.setLevel.bind(workerLogger);
138
+ workerLogger.setLevel = ((level: log.LogLevelDesc, persist?: boolean) => {
139
+ originalWorkerSetLevel(level, persist);
140
+ const numeric = workerLogger.getLevel() as LogLevel;
141
+ workerLogLevelListeners.forEach((cb) => cb(numeric));
142
+ }) as typeof workerLogger.setLevel;
143
+
144
+ /** @internal Subscribe to workerLogger level changes (so E2EE workers can be kept in sync). */
145
+ export function onWorkerLogLevelChanged(cb: (level: LogLevel) => void): () => void {
146
+ workerLogLevelListeners.add(cb);
147
+ return () => {
148
+ workerLogLevelListeners.delete(cb);
149
+ };
150
+ }
151
+
152
+ /** @internal Test-only accessor: current number of workerLogger level listeners. */
153
+ export function getWorkerLogLevelListenerCount(): number {
154
+ return workerLogLevelListeners.size;
155
+ }
@@ -173,6 +173,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
173
173
  return !!this.reconnectTimeout;
174
174
  }
175
175
 
176
+ get serverVersion(): string | undefined {
177
+ return (
178
+ this.latestJoinResponse?.serverInfo?.version ||
179
+ this.latestJoinResponse?.serverVersion ||
180
+ undefined
181
+ );
182
+ }
183
+
176
184
  /**
177
185
  * Owns the data channels: the three flow-controlled publisher wrappers (engine-lifetime; the
178
186
  * RTCDataChannel handles underneath are attached/detached as peer connections come and go, with
@@ -655,7 +663,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
655
663
  sdp: sd.sdp,
656
664
  midToTrackId,
657
665
  });
658
- this.midToTrackId = midToTrackId;
666
+ // in dual PC mode the publisher answer carries no mapping (the server's publisher
667
+ // transport has no sending tracks) and must not clobber the subscriber offer mapping
668
+ if (this.pcManager.mode === 'publisher-only') {
669
+ this.midToTrackId = midToTrackId;
670
+ }
659
671
  await this.pcManager.setPublisherAnswer(sd, offerId);
660
672
  };
661
673
 
@@ -918,12 +930,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
918
930
  this.log.error('Received encrypted packet but E2EE not set up');
919
931
  return;
920
932
  }
921
- const decryptedData = await this.e2eeManager?.handleEncryptedData(
922
- dp.value.value.encryptedValue as NonSharedUint8Array,
923
- dp.value.value.iv as NonSharedUint8Array,
924
- dp.participantIdentity,
925
- dp.value.value.keyIndex,
926
- );
933
+ let decryptedData;
934
+ try {
935
+ decryptedData = await this.e2eeManager.handleEncryptedData(
936
+ dp.value.value.encryptedValue as NonSharedUint8Array,
937
+ dp.value.value.iv as NonSharedUint8Array,
938
+ dp.participantIdentity,
939
+ dp.value.value.keyIndex,
940
+ );
941
+ } catch (err) {
942
+ this.log.debug('failed to decrypt data packet', {
943
+ error: err,
944
+ participantIdentity: dp.participantIdentity,
945
+ });
946
+ return;
947
+ }
927
948
  const decryptedPacket = EncryptedPacketPayload.fromBinary(decryptedData.payload);
928
949
  const newDp = new DataPacket({
929
950
  value: decryptedPacket.value,
package/src/room/Room.ts CHANGED
@@ -339,7 +339,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
339
339
  this.log,
340
340
  this.outgoingDataStreamManager,
341
341
  this.getRemoteParticipantClientProtocol,
342
- () => this.engine.latestJoinResponse?.serverInfo?.version,
342
+ () => this.engine?.serverVersion,
343
343
  );
344
344
  this.rpcClientManager.on('sendDataPacket', ({ packet }) => {
345
345
  this.engine?.sendDataPacket(packet, DataChannelKind.RELIABLE);
@@ -19,6 +19,7 @@ import {
19
19
  TrackInfo,
20
20
  TrackUnpublishedResponse,
21
21
  UserPacket,
22
+ VideoLayer_Mode,
22
23
  protoInt64,
23
24
  } from '@livekit/protocol';
24
25
  import { SignalConnectionState } from '../../api/SignalClient';
@@ -101,6 +102,8 @@ import {
101
102
  isLocalTrack,
102
103
  isLocalVideoTrack,
103
104
  isSVCCodec,
105
+ isSVCSimulcast,
106
+ isSVCSimulcastSupportedByServer,
104
107
  isSafari17Based,
105
108
  isVideoCodec,
106
109
  isVideoTrack,
@@ -108,12 +111,14 @@ import {
108
111
  sleep,
109
112
  supportsAV1,
110
113
  supportsVP9,
114
+ usesLegacySVCEncodings,
111
115
  } from '../utils';
112
116
  import Participant from './Participant';
113
117
  import type { ParticipantTrackPermission } from './ParticipantTrackPermission';
114
118
  import { trackPermissionToProto } from './ParticipantTrackPermission';
115
119
  import type RemoteParticipant from './RemoteParticipant';
116
120
  import {
121
+ computeStartTargetBitrate,
117
122
  computeTrackBackupEncodings,
118
123
  computeVideoEncodings,
119
124
  getDefaultDegradationPreference,
@@ -1135,7 +1140,19 @@ export default class LocalParticipant extends Participant {
1135
1140
  req.height = dims.height;
1136
1141
  // for svc codecs, disable simulcast and use vp8 for backup codec
1137
1142
  if (isLocalVideoTrack(track)) {
1138
- if (isSVCCodec(videoCodec)) {
1143
+ if (
1144
+ isSVCSimulcast(videoCodec, opts) &&
1145
+ (usesLegacySVCEncodings() || !isSVCSimulcastSupportedByServer(this.engine?.serverVersion))
1146
+ ) {
1147
+ opts.simulcast = false;
1148
+ this.log.info(
1149
+ 'SVC simulcast is not supported, disabling simulcast.',
1150
+ getLogContextFromTrack(track),
1151
+ );
1152
+ }
1153
+
1154
+ const svcSimulcast = isSVCSimulcast(videoCodec, opts);
1155
+ if (isSVCCodec(videoCodec) && !svcSimulcast) {
1139
1156
  if (track.source === Track.Source.ScreenShare) {
1140
1157
  // vp9 svc with screenshare cannot encode multiple spatial layers
1141
1158
  // doing so reduces publish resolution to minimal resolution
@@ -1157,12 +1174,14 @@ export default class LocalParticipant extends Participant {
1157
1174
  opts.scalabilityMode = opts.scalabilityMode ?? 'L3T3_KEY';
1158
1175
  }
1159
1176
 
1160
- req.simulcastCodecs = [
1161
- new SimulcastCodec({
1162
- codec: videoCodec,
1163
- cid: track.mediaStreamTrack.id,
1164
- }),
1165
- ];
1177
+ const primaryCodec = new SimulcastCodec({
1178
+ codec: videoCodec,
1179
+ cid: track.mediaStreamTrack.id,
1180
+ });
1181
+ if (svcSimulcast) {
1182
+ primaryCodec.videoLayerMode = VideoLayer_Mode.ONE_SPATIAL_LAYER_PER_STREAM;
1183
+ }
1184
+ req.simulcastCodecs = [primaryCodec];
1166
1185
 
1167
1186
  // set up backup
1168
1187
  if (opts.backupCodec === true) {
@@ -1197,7 +1216,7 @@ export default class LocalParticipant extends Participant {
1197
1216
  req.width,
1198
1217
  req.height,
1199
1218
  encodings,
1200
- isSVCCodec(opts.videoCodec),
1219
+ isSVCCodec(opts.videoCodec) && !isSVCSimulcast(opts.videoCodec, opts),
1201
1220
  );
1202
1221
  } else if (track.kind === Track.Kind.Audio) {
1203
1222
  encodings = [
@@ -1253,12 +1272,9 @@ export default class LocalParticipant extends Participant {
1253
1272
  });
1254
1273
  }
1255
1274
  } else if (track.codec && isVideoCodec(track.codec)) {
1256
- // Apply start bitrate for all video codecs to prevent initial blurriness.
1257
- // - SVC codecs: use first encoding's bitrate (single stream with built-in layers)
1258
- // - Simulcast: sum all encoding bitrates (independent streams, BWE needs total)
1259
- const targetBitrate = isSVCCodec(track.codec)
1260
- ? (encodings[0]?.maxBitrate ?? 0)
1261
- : encodings.reduce((sum, enc) => sum + (enc.maxBitrate ?? 0), 0);
1275
+ // Apply start bitrate for all video codecs to prevent initial blurriness,
1276
+ // see computeStartTargetBitrate
1277
+ const targetBitrate = computeStartTargetBitrate(track.codec, opts, encodings);
1262
1278
  if (targetBitrate > 0) {
1263
1279
  this.engine.pcManager.publisher.setTrackCodecBitrate({
1264
1280
  cid: req.cid,
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import { ScreenSharePresets, VideoPreset, VideoPresets, VideoPresets43 } from '../track/options';
3
3
  import {
4
4
  computeDefaultScreenShareSimulcastPresets,
5
+ computeStartTargetBitrate,
5
6
  computeVideoEncodings,
6
7
  determineAppropriateEncoding,
7
8
  presets43,
@@ -116,6 +117,78 @@ describe('computeVideoEncodings', () => {
116
117
  expect(encodings![0].scaleResolutionDownBy).toBe(1);
117
118
  });
118
119
 
120
+ // svc carries the scalabilityMode on the first encoding only (whether it emits a
121
+ // single encoding or the legacy multi-encoding shape), simulcast carries it on all
122
+ const countScalabilityModes = (encodings?: RTCRtpEncodingParameters[]) =>
123
+ /* @ts-ignore */
124
+ encodings!.filter((encoding) => encoding.scalabilityMode !== undefined).length;
125
+
126
+ it('keeps svc for an svc codec without simulcast', () => {
127
+ const encodings = computeVideoEncodings(false, 960, 540, {
128
+ simulcast: false,
129
+ videoCodec: 'vp9',
130
+ scalabilityMode: 'L3T3_KEY',
131
+ });
132
+ /* @ts-ignore */
133
+ expect(encodings![0].scalabilityMode).toBe('L3T3_KEY');
134
+ expect(countScalabilityModes(encodings)).toBe(1);
135
+ });
136
+
137
+ it('keeps svc for an svc codec with a multi spatial layer mode even if simulcast is set', () => {
138
+ const encodings = computeVideoEncodings(false, 960, 540, {
139
+ simulcast: true,
140
+ videoCodec: 'vp9',
141
+ scalabilityMode: 'L3T3_KEY',
142
+ });
143
+ /* @ts-ignore */
144
+ expect(encodings![0].scalabilityMode).toBe('L3T3_KEY');
145
+ expect(countScalabilityModes(encodings)).toBe(1);
146
+ });
147
+
148
+ it('returns a simulcast ladder for an svc codec with simulcast and an L1Tx mode', () => {
149
+ for (const videoCodec of ['vp9', 'av1'] as const) {
150
+ const encodings = computeVideoEncodings(false, 960, 540, {
151
+ simulcast: true,
152
+ videoCodec,
153
+ scalabilityMode: 'L1T2',
154
+ });
155
+ expect(encodings).toHaveLength(3);
156
+ expect(encodings!.map((e) => e.rid)).toEqual(['q', 'h', 'f']);
157
+ // every encoding needs both scalabilityMode and scaleResolutionDownBy for chrome
158
+ // M113+ to treat them as real simulcast rather than legacy svc
159
+ encodings!.forEach((encoding) => {
160
+ /* @ts-ignore */
161
+ expect(encoding.scalabilityMode).toBe('L1T2');
162
+ expect(encoding.scaleResolutionDownBy).toBeGreaterThanOrEqual(1);
163
+ });
164
+ }
165
+ });
166
+
167
+ it('sets the scalability mode on a single encoding svc simulcast ladder', () => {
168
+ const encodings = computeVideoEncodings(false, 100, 120, {
169
+ simulcast: true,
170
+ videoCodec: 'vp9',
171
+ scalabilityMode: 'L1T3',
172
+ });
173
+ expect(encodings).toHaveLength(1);
174
+ expect(encodings![0].rid).toBe('q');
175
+ /* @ts-ignore */
176
+ expect(encodings![0].scalabilityMode).toBe('L1T3');
177
+ });
178
+
179
+ it('does not set a scalability mode for non-svc simulcast', () => {
180
+ const encodings = computeVideoEncodings(false, 960, 540, {
181
+ simulcast: true,
182
+ videoCodec: 'vp8',
183
+ scalabilityMode: 'L1T2',
184
+ });
185
+ expect(encodings).toHaveLength(3);
186
+ encodings!.forEach((encoding) => {
187
+ /* @ts-ignore */
188
+ expect(encoding.scalabilityMode).toBeUndefined();
189
+ });
190
+ });
191
+
119
192
  // it('respects default backup codec encoding', () => {
120
193
  // const vp8Encodings = computeTrackBackupEncodings(false, 100, 120, { simulcast: true });
121
194
  // const h264Encodings = computeVideoEncodings(false, 100, 120, {
@@ -193,3 +266,63 @@ describe('screenShareSimulcastDefaults', () => {
193
266
  expect(defaultSimulcastLayers[0].encoding.maxBitrate).toBe(375000);
194
267
  });
195
268
  });
269
+
270
+ describe('computeStartTargetBitrate', () => {
271
+ // ordered q..f, as encodingsFromPresets builds them
272
+ const simulcastLadder: RTCRtpEncodingParameters[] = [
273
+ { rid: 'q', maxBitrate: 160_000 },
274
+ { rid: 'h', maxBitrate: 450_000 },
275
+ { rid: 'f', maxBitrate: 680_000 },
276
+ ];
277
+
278
+ it('sums the ladder for plain simulcast', () => {
279
+ expect(computeStartTargetBitrate('vp8', { simulcast: true }, simulcastLadder)).toBe(1_290_000);
280
+ });
281
+
282
+ it('sums the ladder for vp9/av1 published as rid simulcast', () => {
283
+ // encodings[0] is the *smallest* layer here, so taking it would under-hint BWE
284
+ for (const codec of ['vp9', 'av1'] as const) {
285
+ expect(
286
+ computeStartTargetBitrate(
287
+ codec,
288
+ { simulcast: true, videoCodec: codec, scalabilityMode: 'L1T2' },
289
+ simulcastLadder,
290
+ ),
291
+ ).toBe(1_290_000);
292
+ }
293
+ });
294
+
295
+ it('uses the single encoding for vp9/av1 svc', () => {
296
+ for (const codec of ['vp9', 'av1'] as const) {
297
+ expect(
298
+ computeStartTargetBitrate(
299
+ codec,
300
+ { simulcast: false, videoCodec: codec, scalabilityMode: 'L3T3_KEY' },
301
+ [{ maxBitrate: 680_000 }],
302
+ ),
303
+ ).toBe(680_000);
304
+ }
305
+ });
306
+
307
+ it('uses the first encoding for the legacy svc shape, which is ordered f..q', () => {
308
+ // legacy SVC pushes videoRids[2 - i], so encodings[0] carries the full bitrate
309
+ const legacySvc: RTCRtpEncodingParameters[] = [
310
+ { rid: 'f', maxBitrate: 680_000 },
311
+ { rid: 'h', maxBitrate: 226_667 },
312
+ { rid: 'q', maxBitrate: 75_556 },
313
+ ];
314
+ expect(
315
+ computeStartTargetBitrate(
316
+ 'vp9',
317
+ { simulcast: false, videoCodec: 'vp9', scalabilityMode: 'L3T3_KEY' },
318
+ legacySvc,
319
+ ),
320
+ ).toBe(680_000);
321
+ });
322
+
323
+ it('handles missing bitrates and empty encodings', () => {
324
+ expect(computeStartTargetBitrate('vp8', { simulcast: true }, [])).toBe(0);
325
+ expect(computeStartTargetBitrate('vp9', undefined, [])).toBe(0);
326
+ expect(computeStartTargetBitrate('vp8', { simulcast: true }, [{ rid: 'q' }])).toBe(0);
327
+ });
328
+ });