@unboundcx/video-sdk-client 2.0.9 → 2.0.11

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,16 +995,20 @@ 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
+ // Relative weights per rid — matches produce(); the budget is split so
999
+ // the ACTIVE layers SUM to maxBitrate (simulcast total = sum of layers).
1000
+ const WEIGHT = { l: 0.15, m: 0.35, h: 0.65 };
987
1001
 
988
1002
  try {
989
1003
  const params = sender.getParameters();
990
1004
  if (!params.encodings || params.encodings.length < 2) return;
991
1005
  let changed = false;
1006
+ // First pass: decide which layers stay active under the res cap.
1007
+ const settings = this.streams.camera?.getVideoTracks()[0]?.getSettings();
1008
+ const captureWidth = settings?.width || 1920;
992
1009
  for (const enc of params.encodings) {
993
1010
  // scaleResolutionDownBy tells us this layer's output width
994
1011
  // relative to the captured frame; below the cap stays active.
995
- const settings = this.streams.camera?.getVideoTracks()[0]?.getSettings();
996
- const captureWidth = settings?.width || 1920;
997
1012
  const layerWidth = captureWidth / (enc.scaleResolutionDownBy || 1);
998
1013
  const wantActive = layerWidth <= capWidth + 1; // +1 for rounding
999
1014
  if (enc.active !== wantActive) {
@@ -1001,11 +1016,35 @@ export class LocalMediaManager extends EventEmitter {
1001
1016
  changed = true;
1002
1017
  }
1003
1018
  }
1019
+ // Second pass: split the budget across the layers that remain active,
1020
+ // normalizing weights so the active set sums to the budget.
1021
+ if (maxBitrate > 0) {
1022
+ const active = params.encodings.filter((e) => e.active !== false);
1023
+ const totalWeight = active.reduce(
1024
+ (s, e) => s + (WEIGHT[e.rid] ?? 1.0),
1025
+ 0
1026
+ );
1027
+ for (const enc of active) {
1028
+ const w = WEIGHT[enc.rid] ?? 1.0;
1029
+ const share = Math.round((maxBitrate * w) / totalWeight);
1030
+ const next = Math.min(enc.maxBitrate || share, share);
1031
+ if (enc.maxBitrate !== next) {
1032
+ enc.maxBitrate = next;
1033
+ changed = true;
1034
+ }
1035
+ }
1036
+ }
1004
1037
  if (changed) {
1005
1038
  sender.setParameters(params);
1006
1039
  this.logger.info(
1007
1040
  'LocalMediaManager :: _capSendSimulcastToResolution ::',
1008
- { sendResolution, capWidth }
1041
+ {
1042
+ sendResolution,
1043
+ capWidth,
1044
+ maxBitrate: maxBitrate
1045
+ ? `${(maxBitrate / 1000).toFixed(0)}kbps`
1046
+ : 'none',
1047
+ }
1009
1048
  );
1010
1049
  }
1011
1050
  } catch (err) {
@@ -673,6 +673,42 @@ export class MediasoupManager extends EventEmitter {
673
673
  bitrate: "800kbps",
674
674
  });
675
675
  }
676
+
677
+ // HARD bitrate ceiling (data-saver / quality level). On a metered link
678
+ // the profile's sendMaxBitrate is a budget the encoder must not exceed
679
+ // — even during high motion, when resolution/fps caps alone let the
680
+ // rate spike. With simulcast, total bandwidth is the SUM of all active
681
+ // layers, so the budget is split across the layers present such that
682
+ // the layers SUM to the budget (not each capped at the full budget —
683
+ // that earlier bug let the total reach ~1.6× the cap). The top layer
684
+ // gets the lion's share; lower layers get proportionally less.
685
+ // (Screen share never reaches here.)
686
+ const maxBitrate = Number(options.maxBitrate) || 0;
687
+ if (maxBitrate > 0 && produceOptions.encodings?.length) {
688
+ // Relative weights per rid; we normalize over the layers actually
689
+ // present so the allocated total always equals the budget whether
690
+ // there are 1, 2, or 3 layers.
691
+ const WEIGHT = { l: 0.15, m: 0.35, h: 0.65 };
692
+ const encs = produceOptions.encodings;
693
+ const totalWeight = encs.reduce(
694
+ (sum, e) => sum + (WEIGHT[e.rid] ?? 1.0),
695
+ 0,
696
+ );
697
+ for (const enc of encs) {
698
+ const w = WEIGHT[enc.rid] ?? 1.0;
699
+ const share = Math.round((maxBitrate * w) / totalWeight);
700
+ // Only ever lower — never raise a layer above its tuned value.
701
+ enc.maxBitrate = Math.min(enc.maxBitrate || share, share);
702
+ }
703
+ const allocated = encs.reduce((s, e) => s + (e.maxBitrate || 0), 0);
704
+ this.logger.info("VIDEO_QUALITY :: applied hard bitrate ceiling", {
705
+ budget: `${(maxBitrate / 1000).toFixed(0)}kbps`,
706
+ allocatedTotal: `${(allocated / 1000).toFixed(0)}kbps`,
707
+ encodings: encs.map(
708
+ (e) => `${e.rid}: ${(e.maxBitrate / 1000).toFixed(0)}kbps`,
709
+ ),
710
+ });
711
+ }
676
712
  } // end camera-profile else (matches `if (isScreenShare)`)
677
713
  }
678
714
 
@@ -452,15 +452,31 @@ export class StatsCollector {
452
452
  report.type === 'outbound-rtp' ||
453
453
  report.type === 'remote-outbound-rtp'
454
454
  ) {
455
- videoStats.packetsSent = this.validateNumber(report.packetsSent);
456
- videoStats.bytesSent = this.validateNumber(report.bytesSent);
457
- videoStats.framesSent = this.validateNumber(report.framesSent);
458
- videoStats.framesPerSecond = this.validateNumber(
459
- report.framesPerSecond,
460
- );
461
- videoStats.frameHeight = this.validateNumber(report.frameHeight);
462
- videoStats.frameWidth = this.validateNumber(report.frameWidth);
463
- videoStats.scalabilityMode = report.scalabilityMode;
455
+ // SIMULCAST: there's one outbound-rtp report PER active layer
456
+ // (l/m/h). Counters that represent the whole producer (bytes,
457
+ // packets, frames) accumulate across layers; resolution/fps must
458
+ // reflect the TOP active layer, so only overwrite those when this
459
+ // report is a bigger frame than what we've seen. Without this the
460
+ // last report (often the lowest layer) wins and the panel shows
461
+ // e.g. 270p while we're actually sending 540p.
462
+ videoStats.packetsSent =
463
+ (videoStats.packetsSent || 0) +
464
+ this.validateNumber(report.packetsSent);
465
+ videoStats.bytesSent =
466
+ (videoStats.bytesSent || 0) +
467
+ this.validateNumber(report.bytesSent);
468
+ videoStats.framesSent =
469
+ (videoStats.framesSent || 0) +
470
+ this.validateNumber(report.framesSent);
471
+ const w = this.validateNumber(report.frameWidth);
472
+ if (w >= (videoStats.frameWidth || 0)) {
473
+ videoStats.frameWidth = w;
474
+ videoStats.frameHeight = this.validateNumber(report.frameHeight);
475
+ videoStats.framesPerSecond = this.validateNumber(
476
+ report.framesPerSecond,
477
+ );
478
+ videoStats.scalabilityMode = report.scalabilityMode;
479
+ }
464
480
  videoStats.qpSum = report.qpSum;
465
481
  videoStats.totalPacketSendDelay = this.validateNumber(
466
482
  report.totalPacketSendDelay,
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.11",
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
  },