@stream-io/video-client 1.40.3 → 1.41.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.
@@ -109,4 +109,109 @@ a=fmtp:103 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001
109
109
  'a=fmtp:103 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f;x-google-start-bitrate=1050',
110
110
  );
111
111
  });
112
+
113
+ it('clamps to 300kbps when factor is 0', () => {
114
+ const offerSdp = `v=0
115
+ o=- 123 2 IN IP4 127.0.0.1
116
+ s=-
117
+ t=0 0
118
+ m=video 9 UDP/TLS/RTP/SAVPF 98
119
+ c=IN IP4 0.0.0.0
120
+ a=mid:0
121
+ a=rtpmap:98 VP9/90000
122
+ a=fmtp:98 profile-id=0
123
+ `;
124
+
125
+ const result = setStartBitrate(offerSdp, 1500, 0, '0'); // 0 -> clamped to 300
126
+ expect(result).toContain(
127
+ 'a=fmtp:98 profile-id=0;x-google-start-bitrate=300',
128
+ );
129
+ });
130
+
131
+ it('uses max bitrate when factor is 1', () => {
132
+ const offerSdp = `v=0
133
+ o=- 123 2 IN IP4 127.0.0.1
134
+ s=-
135
+ t=0 0
136
+ m=video 9 UDP/TLS/RTP/SAVPF 98
137
+ c=IN IP4 0.0.0.0
138
+ a=mid:0
139
+ a=rtpmap:98 VP9/90000
140
+ a=fmtp:98 profile-id=0
141
+ `;
142
+
143
+ const result = setStartBitrate(offerSdp, 1500, 1, '0'); // should be 1500
144
+ expect(result).toContain(
145
+ 'a=fmtp:98 profile-id=0;x-google-start-bitrate=1500',
146
+ );
147
+ });
148
+
149
+ it('clamps to max bitrate when factor exceeds 1', () => {
150
+ const offerSdp = `v=0
151
+ o=- 123 2 IN IP4 127.0.0.1
152
+ s=-
153
+ t=0 0
154
+ m=video 9 UDP/TLS/RTP/SAVPF 98
155
+ c=IN IP4 0.0.0.0
156
+ a=mid:0
157
+ a=rtpmap:98 VP9/90000
158
+ a=fmtp:98 profile-id=0
159
+ `;
160
+
161
+ const result = setStartBitrate(offerSdp, 1500, 1.5, '0'); // 2250 -> clamped to 1500
162
+ expect(result).toContain(
163
+ 'a=fmtp:98 profile-id=0;x-google-start-bitrate=1500',
164
+ );
165
+ });
166
+
167
+ it('creates fmtp line when none exists for target codec', () => {
168
+ const offerSdp = `v=0
169
+ o=- 123 2 IN IP4 127.0.0.1
170
+ s=-
171
+ t=0 0
172
+ m=video 9 UDP/TLS/RTP/SAVPF 98
173
+ c=IN IP4 0.0.0.0
174
+ a=mid:0
175
+ a=rtpmap:98 VP9/90000
176
+ `;
177
+
178
+ const result = setStartBitrate(offerSdp, 1500, 0.5, '0'); // 750kbps
179
+ expect(result).toContain('a=fmtp:98 x-google-start-bitrate=750');
180
+ });
181
+
182
+ it('creates fmtp lines for multiple codecs without existing fmtp', () => {
183
+ const offerSdp = `v=0
184
+ o=- 123 2 IN IP4 127.0.0.1
185
+ s=-
186
+ t=0 0
187
+ m=video 9 UDP/TLS/RTP/SAVPF 98 103
188
+ c=IN IP4 0.0.0.0
189
+ a=mid:0
190
+ a=rtpmap:98 VP9/90000
191
+ a=rtpmap:103 H264/90000
192
+ `;
193
+
194
+ const result = setStartBitrate(offerSdp, 1500, 0.5, '0'); // 750kbps
195
+ expect(result).toContain('a=fmtp:98 x-google-start-bitrate=750');
196
+ expect(result).toContain('a=fmtp:103 x-google-start-bitrate=750');
197
+ });
198
+
199
+ it('never exceeds maxBitrateKbps even when it is below 300', () => {
200
+ const offerSdp = `v=0
201
+ o=- 123 2 IN IP4 127.0.0.1
202
+ s=-
203
+ t=0 0
204
+ m=video 9 UDP/TLS/RTP/SAVPF 98
205
+ c=IN IP4 0.0.0.0
206
+ a=mid:0
207
+ a=rtpmap:98 VP9/90000
208
+ a=fmtp:98 profile-id=0
209
+ `;
210
+
211
+ // maxBitrateKbps (200) is less than minimum (300), should clamp to maxBitrateKbps
212
+ const result = setStartBitrate(offerSdp, 200, 1, '0');
213
+ expect(result).toContain(
214
+ 'a=fmtp:98 profile-id=0;x-google-start-bitrate=200',
215
+ );
216
+ });
112
217
  });
@@ -29,11 +29,13 @@ export const extractMid = (
29
29
  return String(transceiverInitIndex);
30
30
  };
31
31
 
32
- /*
33
- * Sets the start bitrate for the VP9 and H264 codecs in the SDP.
32
+ /**
33
+ * Sets the start bitrate for the VP9, H264, and AV1 codecs in the SDP.
34
34
  *
35
35
  * @param offerSdp the offer SDP to modify.
36
- * @param startBitrate the start bitrate in kbps to set. Default is 1000 kbps.
36
+ * @param maxBitrateKbps the maximum bitrate in kbps.
37
+ * @param startBitrateFactor the factor (0-1) to multiply with maxBitrateKbps to get the start bitrate.
38
+ * @param targetMid the media ID to target.
37
39
  */
38
40
  export const setStartBitrate = (
39
41
  offerSdp: string,
@@ -42,9 +44,10 @@ export const setStartBitrate = (
42
44
  targetMid: string,
43
45
  ): string => {
44
46
  // start bitrate should be between 300kbps and max-bitrate-kbps
45
- const startBitrate = Math.max(
46
- Math.min(maxBitrateKbps, startBitrateFactor * maxBitrateKbps),
47
- 300,
47
+ // Clamp to max first, then ensure minimum of 300 (but never exceed max)
48
+ const startBitrate = Math.min(
49
+ maxBitrateKbps,
50
+ Math.max(300, startBitrateFactor * maxBitrateKbps),
48
51
  );
49
52
  const parsedSdp = parse(offerSdp);
50
53
  const targetCodecs = new Set(['av1', 'vp9', 'h264']);
@@ -56,13 +59,28 @@ export const setStartBitrate = (
56
59
  for (const rtp of media.rtp) {
57
60
  if (!targetCodecs.has(rtp.codec.toLowerCase())) continue;
58
61
 
59
- for (const fmtp of media.fmtp) {
60
- if (fmtp.payload === rtp.payload) {
61
- if (!fmtp.config.includes('x-google-start-bitrate')) {
62
- fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
63
- }
64
- break;
62
+ // Find existing fmtp entry for this payload
63
+ // Guard against media.fmtp being undefined when SDP has no a=fmtp lines
64
+ const fmtpList = media.fmtp ?? (media.fmtp = []);
65
+ const existingFmtp = fmtpList.find(
66
+ (fmtp) => fmtp.payload === rtp.payload,
67
+ );
68
+
69
+ if (existingFmtp) {
70
+ // Append to existing fmtp if not already present
71
+ // Guard against undefined or empty config from malformed SDP
72
+ const config = existingFmtp.config ?? '';
73
+ if (!config.includes('x-google-start-bitrate')) {
74
+ existingFmtp.config = config
75
+ ? `${config};x-google-start-bitrate=${startBitrate}`
76
+ : `x-google-start-bitrate=${startBitrate}`;
65
77
  }
78
+ } else {
79
+ // Create new fmtp entry if none exists
80
+ fmtpList.push({
81
+ payload: rtp.payload,
82
+ config: `x-google-start-bitrate=${startBitrate}`,
83
+ });
66
84
  }
67
85
  }
68
86
  }
@@ -12,6 +12,7 @@ import type { Patch } from './rxUtils';
12
12
  import * as RxUtils from './rxUtils';
13
13
  import { CallingState } from './CallingState';
14
14
  import {
15
+ type CallRecordingType,
15
16
  type ClosedCaptionsSettings,
16
17
  type StreamVideoParticipant,
17
18
  type StreamVideoParticipantPatch,
@@ -49,12 +50,13 @@ import {
49
50
  import { Timestamp } from '../gen/google/protobuf/timestamp';
50
51
  import { ReconnectDetails } from '../gen/video/sfu/event/events';
51
52
  import {
53
+ CallGrants,
52
54
  CallState as SfuCallState,
53
55
  Pin,
54
56
  TrackType,
55
- CallGrants,
56
57
  } from '../gen/video/sfu/models/models';
57
58
  import { Comparator, defaultSortPreset } from '../sorting';
59
+ import { ensureExhausted } from '../helpers/ensureExhausted';
58
60
  import { hasScreenShare } from '../helpers/participantUtils';
59
61
  import { videoLoggerSystem } from '../logger';
60
62
 
@@ -96,6 +98,8 @@ export class CallState {
96
98
  undefined,
97
99
  );
98
100
  private recordingSubject = new BehaviorSubject<boolean>(false);
101
+ private individualRecordingSubject = new BehaviorSubject<boolean>(false);
102
+ private rawRecordingSubject = new BehaviorSubject<boolean>(false);
99
103
  private sessionSubject = new BehaviorSubject<CallSessionResponse | undefined>(
100
104
  undefined,
101
105
  );
@@ -277,6 +281,16 @@ export class CallState {
277
281
  */
278
282
  recording$: Observable<boolean>;
279
283
 
284
+ /**
285
+ * Will provide the recording state of this call.
286
+ */
287
+ individualRecording$: Observable<boolean>;
288
+
289
+ /**
290
+ * Will provide the recording state of this call.
291
+ */
292
+ rawRecording$: Observable<boolean>;
293
+
280
294
  /**
281
295
  * Will provide the session data of this call.
282
296
  */
@@ -462,6 +476,8 @@ export class CallState {
462
476
  );
463
477
  this.participantCount$ = duc(this.participantCountSubject);
464
478
  this.recording$ = duc(this.recordingSubject);
479
+ this.individualRecording$ = duc(this.individualRecordingSubject);
480
+ this.rawRecording$ = duc(this.rawRecordingSubject);
465
481
  this.transcribing$ = duc(this.transcribingSubject);
466
482
  this.captioning$ = duc(this.captioningSubject);
467
483
 
@@ -530,12 +546,15 @@ export class CallState {
530
546
  },
531
547
  'call.permissions_updated': this.updateOwnCapabilities,
532
548
  'call.reaction_new': this.updateParticipantReaction,
533
- 'call.recording_started': () =>
534
- this.setCurrentValue(this.recordingSubject, true),
535
- 'call.recording_stopped': () =>
536
- this.setCurrentValue(this.recordingSubject, false),
537
- 'call.recording_failed': () =>
538
- this.setCurrentValue(this.recordingSubject, false),
549
+ 'call.recording_started': (e) => {
550
+ this.updateFromRecordingEvent(e.recording_type, true);
551
+ },
552
+ 'call.recording_stopped': (e) => {
553
+ this.updateFromRecordingEvent(e.recording_type, false);
554
+ },
555
+ 'call.recording_failed': (e) => {
556
+ this.updateFromRecordingEvent(e.recording_type, false);
557
+ },
539
558
  'call.rejected': (e) => this.updateFromCallResponse(e.call),
540
559
  'call.ring': (e) => this.updateFromCallResponse(e.call),
541
560
  'call.missed': (e) => this.updateFromCallResponse(e.call),
@@ -919,12 +938,26 @@ export class CallState {
919
938
  }
920
939
 
921
940
  /**
922
- * Will provide the recording state of this call.
941
+ * Will provide the composite recording state of this call.
923
942
  */
924
943
  get recording() {
925
944
  return this.getCurrentValue(this.recording$);
926
945
  }
927
946
 
947
+ /**
948
+ * Will provide the individual recording state of this call.
949
+ */
950
+ get individualRecording() {
951
+ return this.getCurrentValue(this.individualRecording$);
952
+ }
953
+
954
+ /**
955
+ * Will provide the raw recording state of this call.
956
+ */
957
+ get rawRecording() {
958
+ return this.getCurrentValue(this.rawRecording$);
959
+ }
960
+
928
961
  /**
929
962
  * Will provide the session data of this call.
930
963
  */
@@ -1272,7 +1305,21 @@ export class CallState {
1272
1305
  this.setCurrentValue(this.customSubject, call.custom);
1273
1306
  this.setCurrentValue(this.egressSubject, call.egress);
1274
1307
  this.setCurrentValue(this.ingressSubject, call.ingress);
1275
- this.setCurrentValue(this.recordingSubject, call.recording);
1308
+ const { individual_recording, composite_recording, raw_recording } =
1309
+ call.egress;
1310
+ this.setCurrentValue(
1311
+ this.recordingSubject,
1312
+ call.recording || composite_recording?.status === 'running',
1313
+ );
1314
+ this.setCurrentValue(
1315
+ this.individualRecordingSubject,
1316
+ individual_recording?.status === 'running',
1317
+ );
1318
+ this.setCurrentValue(
1319
+ this.rawRecordingSubject,
1320
+ raw_recording?.status === 'running',
1321
+ );
1322
+
1276
1323
  const s = this.setCurrentValue(this.sessionSubject, call.session);
1277
1324
  this.updateParticipantCountFromSession(s);
1278
1325
  this.setCurrentValue(this.settingsSubject, call.settings);
@@ -1363,6 +1410,22 @@ export class CallState {
1363
1410
  }));
1364
1411
  };
1365
1412
 
1413
+ private updateFromRecordingEvent = (
1414
+ type: CallRecordingType | undefined,
1415
+ running: boolean,
1416
+ ) => {
1417
+ // handle the legacy format, where `type` is absent in the emitted events
1418
+ if (type === undefined || type === 'composite') {
1419
+ this.setCurrentValue(this.recordingSubject, running);
1420
+ } else if (type === 'individual') {
1421
+ this.setCurrentValue(this.individualRecordingSubject, running);
1422
+ } else if (type === 'raw') {
1423
+ this.setCurrentValue(this.rawRecordingSubject, running);
1424
+ } else {
1425
+ ensureExhausted(type, 'Unknown recording type');
1426
+ }
1427
+ };
1428
+
1366
1429
  private updateParticipantCountFromSession = (
1367
1430
  session: CallSessionResponse | undefined,
1368
1431
  ) => {