@unboundcx/video-sdk-client 2.0.8 → 2.0.10

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.
@@ -791,6 +791,11 @@ export class VideoMeetingClient extends EventEmitter {
791
791
  merged.maxResolution = profile.sendResolution;
792
792
  merged.frameRate = profile.sendFrameRate;
793
793
  }
794
+ // The hard bitrate ceiling always applies when the profile defines one
795
+ // (it's the lever that actually protects a metered link on motion).
796
+ if (profile.sendMaxBitrate) {
797
+ merged.maxBitrate = profile.sendMaxBitrate;
798
+ }
794
799
  return await this.localMedia.publishCamera(merged);
795
800
  }
796
801
 
@@ -276,6 +276,7 @@ export class LocalMediaManager extends EventEmitter {
276
276
  appData: { type: 'video' },
277
277
  simulcast: options.simulcast, // Pass through simulcast option
278
278
  maxResolution: options.maxResolution, // Pass through max resolution preference
279
+ maxBitrate: options.maxBitrate, // Hard bitrate ceiling (data-saver)
279
280
  });
280
281
 
281
282
  this.producers.video = producer;
@@ -880,6 +881,7 @@ export class LocalMediaManager extends EventEmitter {
880
881
  * @param {Object} profile - from videoQualityProfiles.js
881
882
  * @param {string|null} profile.sendResolution - '360p'|'540p'|'720p'|'1080p'|null
882
883
  * @param {number} profile.sendFrameRate
884
+ * @param {number|null} profile.sendMaxBitrate - hard ceiling (bps); null = none
883
885
  * @returns {Promise<void>}
884
886
  */
885
887
  async setSendQuality(profile = {}) {
@@ -946,8 +948,12 @@ export class LocalMediaManager extends EventEmitter {
946
948
  this.streams.camera = stream;
947
949
  this.rawCameraStream = rawStream;
948
950
 
949
- // Cap the simulcast top layer to match the new capture resolution.
950
- this._capSendSimulcastToResolution(profile.sendResolution);
951
+ // Cap the simulcast top layer to match the new capture resolution,
952
+ // and apply the profile's hard bitrate ceiling.
953
+ this._capSendSimulcastToResolution(
954
+ profile.sendResolution,
955
+ profile.sendMaxBitrate || 0
956
+ );
951
957
 
952
958
  this.logger.info('LocalMediaManager :: setSendQuality :: applied', {
953
959
  resolution: `${newTrack.getSettings().width}x${newTrack.getSettings().height}`,
@@ -969,14 +975,19 @@ export class LocalMediaManager extends EventEmitter {
969
975
  }
970
976
 
971
977
  /**
972
- * Deactivate simulcast encodings whose source resolution exceeds the cap,
973
- * via RTCRtpSender.setParameters (no renegotiation). null/'1080p' = no cap
974
- * (reactivate everything). Mirrors QualityMonitor's outbound capping but
975
- * keyed off a user-chosen resolution rather than a 'mid'|'low' target.
978
+ * Apply the send-side quality cap to the live sender via
979
+ * RTCRtpSender.setParameters (no renegotiation). Two things:
980
+ * - deactivate simulcast encodings whose source resolution exceeds the
981
+ * resolution cap (so we don't ship layers above the chosen res), and
982
+ * - apply the HARD bitrate ceiling so the rate can't spike on motion —
983
+ * the same budget produce() applies, mirrored here for the live path.
984
+ * null/'1080p' resolution + 0 bitrate = no cap (reactivate everything,
985
+ * leave bitrates as-is).
976
986
  * @private
977
987
  * @param {string|null} sendResolution
988
+ * @param {number} [maxBitrate=0] - hard ceiling (bps); 0 = no bitrate cap
978
989
  */
979
- _capSendSimulcastToResolution(sendResolution) {
990
+ _capSendSimulcastToResolution(sendResolution, maxBitrate = 0) {
980
991
  const sender = this.producers.video?.rtpSender;
981
992
  if (!sender || typeof sender.getParameters !== 'function') return;
982
993
 
@@ -984,6 +995,8 @@ export class LocalMediaManager extends EventEmitter {
984
995
  // Keep a layer active only if its width is within the cap.
985
996
  const capWidth =
986
997
  { '360p': 640, '540p': 960, '720p': 1280 }[sendResolution] || Infinity;
998
+ // Per-layer fraction of the bitrate budget — matches produce().
999
+ const LAYER_FRACTION = { l: 0.18, m: 0.4, h: 1.0 };
987
1000
 
988
1001
  try {
989
1002
  const params = sender.getParameters();
@@ -1000,12 +1013,27 @@ export class LocalMediaManager extends EventEmitter {
1000
1013
  enc.active = wantActive;
1001
1014
  changed = true;
1002
1015
  }
1016
+ if (maxBitrate > 0) {
1017
+ const frac = LAYER_FRACTION[enc.rid] ?? 1.0;
1018
+ const ceiling = Math.round(maxBitrate * frac);
1019
+ const next = Math.min(enc.maxBitrate || ceiling, ceiling);
1020
+ if (enc.maxBitrate !== next) {
1021
+ enc.maxBitrate = next;
1022
+ changed = true;
1023
+ }
1024
+ }
1003
1025
  }
1004
1026
  if (changed) {
1005
1027
  sender.setParameters(params);
1006
1028
  this.logger.info(
1007
1029
  'LocalMediaManager :: _capSendSimulcastToResolution ::',
1008
- { sendResolution, capWidth }
1030
+ {
1031
+ sendResolution,
1032
+ capWidth,
1033
+ maxBitrate: maxBitrate
1034
+ ? `${(maxBitrate / 1000).toFixed(0)}kbps`
1035
+ : 'none',
1036
+ }
1009
1037
  );
1010
1038
  }
1011
1039
  } catch (err) {
@@ -673,6 +673,32 @@ export class MediasoupManager extends EventEmitter {
673
673
  bitrate: "800kbps",
674
674
  });
675
675
  }
676
+
677
+ // HARD bitrate ceiling (data-saver / quality level). The per-branch
678
+ // numbers above are tuned for full-quality send; on a metered link the
679
+ // profile's sendMaxBitrate is a budget the encoder must not exceed —
680
+ // *even during high motion*, which is exactly when resolution/fps caps
681
+ // alone let the rate spike. Apply it as a ceiling on the top (h) layer,
682
+ // and scale the lower layers proportionally so the simulcast total
683
+ // stays within budget. (Screen share never reaches here.)
684
+ const maxBitrate = Number(options.maxBitrate) || 0;
685
+ if (maxBitrate > 0 && produceOptions.encodings) {
686
+ // Fractions of the budget per layer, matching the l/m/h split used
687
+ // above (~1/8, ~1/3, full). Keys are rids.
688
+ const LAYER_FRACTION = { l: 0.18, m: 0.4, h: 1.0 };
689
+ for (const enc of produceOptions.encodings) {
690
+ const frac = LAYER_FRACTION[enc.rid] ?? 1.0;
691
+ const ceiling = Math.round(maxBitrate * frac);
692
+ // Only ever lower — never raise a layer above its tuned value.
693
+ enc.maxBitrate = Math.min(enc.maxBitrate || ceiling, ceiling);
694
+ }
695
+ this.logger.info("VIDEO_QUALITY :: applied hard bitrate ceiling", {
696
+ maxBitrate: `${(maxBitrate / 1000).toFixed(0)}kbps`,
697
+ encodings: produceOptions.encodings.map(
698
+ (e) => `${e.rid}: ${(e.maxBitrate / 1000).toFixed(0)}kbps`,
699
+ ),
700
+ });
701
+ }
676
702
  } // end camera-profile else (matches `if (isScreenShare)`)
677
703
  }
678
704
 
@@ -11,6 +11,11 @@ export class StatsCollector {
11
11
  this.tracks = new Map(); // trackId -> { participantId, kind }
12
12
  this.onStatsCallback = null; // Host-app callback (UI display)
13
13
  this._internalCallbacks = []; // SDK-internal subscribers (QualityMonitor)
14
+ // Previous cumulative byte counts + timestamp per transport, so we can
15
+ // turn the monotonic bytesSent/bytesReceived counters into an actual
16
+ // throughput RATE (kbps) instead of a meaningless running total. Keyed by
17
+ // transportType ('send' | 'recv'). See calculateBandwidth().
18
+ this._prevBytes = { send: null, recv: null };
14
19
  }
15
20
 
16
21
  /**
@@ -214,37 +219,60 @@ export class StatsCollector {
214
219
  }
215
220
 
216
221
  /**
217
- * Calculate bandwidth from stats
222
+ * Calculate actual throughput from stats.
223
+ *
224
+ * bytesSent / bytesReceived are MONOTONIC cumulative counters (total since
225
+ * the connection started), so summing them yields a meaningless ever-growing
226
+ * number. To get a real RATE we diff the current total against the previous
227
+ * sample and divide by the elapsed time. Returns both kbps (for display) and
228
+ * Mbps (back-compat with existing consumers). The first sample per transport
229
+ * has no baseline, so its rate is 0 until the next cycle establishes a delta.
230
+ *
231
+ * `upload`/`download` (Mbps) are kept for back-compat; `uploadKbps`/
232
+ * `downloadKbps` are the values the quality panel shows.
218
233
  */
219
234
  calculateBandwidth(stats, transportType) {
220
235
  const bandwidth = {
221
- upload: 0,
222
- download: 0,
236
+ upload: 0, // Mbps (back-compat)
237
+ download: 0, // Mbps (back-compat)
238
+ uploadKbps: 0,
239
+ downloadKbps: 0,
223
240
  unit: 'Mbps',
224
241
  };
225
242
 
243
+ // Sum cumulative bytes across all RTP streams for this transport, and take
244
+ // the freshest report timestamp as "now" (RTCStats timestamps are in ms).
245
+ let totalBytes = 0;
246
+ let ts = 0;
247
+ const wantType = transportType === 'send' ? 'outbound-rtp' : 'inbound-rtp';
248
+ const byteField = transportType === 'send' ? 'bytesSent' : 'bytesReceived';
226
249
  for (const report of stats.values()) {
250
+ if (report.type !== wantType) continue;
251
+ totalBytes += Number(report[byteField]) || 0;
252
+ if (report.timestamp && report.timestamp > ts) ts = report.timestamp;
253
+ }
254
+ if (!ts) ts = Date.now();
255
+
256
+ const prev = this._prevBytes[transportType];
257
+ this._prevBytes[transportType] = { bytes: totalBytes, ts };
258
+
259
+ // Need a prior sample to compute a rate. Guard against counter resets
260
+ // (transport recreated → bytes drop) and non-positive elapsed time.
261
+ if (prev && totalBytes >= prev.bytes && ts > prev.ts) {
262
+ const deltaBits = (totalBytes - prev.bytes) * 8;
263
+ const deltaSec = (ts - prev.ts) / 1000;
264
+ const bps = deltaBits / deltaSec;
265
+ const kbps = parseFloat((bps / 1000).toFixed(1));
266
+ const mbps = parseFloat((bps / 1_000_000).toFixed(2));
227
267
  if (transportType === 'send') {
228
- if (report.type === 'outbound-rtp') {
229
- // Calculate upload bandwidth
230
- if (report.bytesSent) {
231
- bandwidth.upload += report.bytesSent * 8; // Convert to bits
232
- }
233
- }
268
+ bandwidth.uploadKbps = kbps;
269
+ bandwidth.upload = mbps;
234
270
  } else {
235
- if (report.type === 'inbound-rtp') {
236
- // Calculate download bandwidth
237
- if (report.bytesReceived) {
238
- bandwidth.download += report.bytesReceived * 8; // Convert to bits
239
- }
240
- }
271
+ bandwidth.downloadKbps = kbps;
272
+ bandwidth.download = mbps;
241
273
  }
242
274
  }
243
275
 
244
- // Convert to Mbps
245
- bandwidth.upload = parseFloat((bandwidth.upload / 1000000).toFixed(2));
246
- bandwidth.download = parseFloat((bandwidth.download / 1000000).toFixed(2));
247
-
248
276
  return bandwidth;
249
277
  }
250
278
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/video-sdk-client",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
4
  "description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -10,6 +10,13 @@
10
10
  // simulcast top layer). null = no cap (use device
11
11
  // default / 1080p).
12
12
  // sendFrameRate capture frame-rate cap.
13
+ // sendMaxBitrate HARD ceiling (bps) on the top simulcast layer.
14
+ // This is the lever that actually protects a
15
+ // metered link: resolution/fps are encoder
16
+ // *inputs* it tries to honor, but maxBitrate is a
17
+ // budget it cannot exceed — even during high
18
+ // motion. null = no explicit cap (encoder default
19
+ // for the resolution).
13
20
  //
14
21
  // receive — the ceiling on what we pull from every remote peer:
15
22
  // maxSpatialLayer highest simulcast spatial layer we'll accept
@@ -38,6 +45,7 @@ export const VIDEO_QUALITY_PROFILES = Object.freeze({
38
45
  label: 'Auto',
39
46
  sendResolution: null, // device default (≈1080p)
40
47
  sendFrameRate: 30,
48
+ sendMaxBitrate: null, // no explicit cap
41
49
  maxSpatialLayer: null, // no ceiling — tile-size auto-selection wins
42
50
  maxTemporalLayer: null,
43
51
  },
@@ -48,14 +56,18 @@ export const VIDEO_QUALITY_PROFILES = Object.freeze({
48
56
  label: 'High',
49
57
  sendResolution: '1080p',
50
58
  sendFrameRate: 30,
59
+ sendMaxBitrate: null, // no explicit cap (encoder default for 1080p)
51
60
  maxSpatialLayer: 2,
52
61
  maxTemporalLayer: 2,
53
62
  },
54
- // The "Data saver" target. Meaningful savings, still very usable face video.
63
+ // The "Data saver" target tuned for metered cellular / Starlink. 540p at a
64
+ // slightly reduced frame rate, with a HARD 600kbps send ceiling so motion
65
+ // can't spike the link. Receive capped at the mid simulcast layer.
55
66
  medium: {
56
67
  label: 'Medium',
57
68
  sendResolution: '540p',
58
- sendFrameRate: 30,
69
+ sendFrameRate: 24,
70
+ sendMaxBitrate: 600000, // hard ~600kbps ceiling — guaranteed even on motion
59
71
  maxSpatialLayer: 1, // never accept the 1080p layer from anyone
60
72
  maxTemporalLayer: 2,
61
73
  },
@@ -64,6 +76,7 @@ export const VIDEO_QUALITY_PROFILES = Object.freeze({
64
76
  label: 'Low',
65
77
  sendResolution: '360p',
66
78
  sendFrameRate: 15,
79
+ sendMaxBitrate: 250000, // hard ~250kbps ceiling
67
80
  maxSpatialLayer: 0, // lowest layer only
68
81
  maxTemporalLayer: 1, // ≈half frame-rate on receive
69
82
  },