@unboundcx/video-sdk-client 2.0.9 → 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/video-sdk-client",
3
- "version": "2.0.9",
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
  },