@unboundcx/video-sdk-client 2.0.10 → 2.0.12

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.
@@ -829,6 +829,10 @@ export class VideoMeetingClient extends EventEmitter {
829
829
  profile.maxSpatialLayer,
830
830
  profile.maxTemporalLayer,
831
831
  );
832
+ // Keep the send-cap flag in sync on every level change so QualityMonitor
833
+ // discounts the BWE floor while we're intentionally throttled — even on
834
+ // the mid-call path that doesn't re-produce.
835
+ this.mediasoup.setSendBitrateCap(profile.sendMaxBitrate || 0);
832
836
  }
833
837
  if (this.remoteMedia) {
834
838
  this.remoteMedia.setLayerCeiling(
@@ -995,28 +995,39 @@ export class LocalMediaManager extends EventEmitter {
995
995
  // Keep a layer active only if its width is within the cap.
996
996
  const capWidth =
997
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 };
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 };
1000
1001
 
1001
1002
  try {
1002
1003
  const params = sender.getParameters();
1003
1004
  if (!params.encodings || params.encodings.length < 2) return;
1004
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;
1005
1009
  for (const enc of params.encodings) {
1006
1010
  // scaleResolutionDownBy tells us this layer's output width
1007
1011
  // relative to the captured frame; below the cap stays active.
1008
- const settings = this.streams.camera?.getVideoTracks()[0]?.getSettings();
1009
- const captureWidth = settings?.width || 1920;
1010
1012
  const layerWidth = captureWidth / (enc.scaleResolutionDownBy || 1);
1011
1013
  const wantActive = layerWidth <= capWidth + 1; // +1 for rounding
1012
1014
  if (enc.active !== wantActive) {
1013
1015
  enc.active = wantActive;
1014
1016
  changed = true;
1015
1017
  }
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);
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);
1020
1031
  if (enc.maxBitrate !== next) {
1021
1032
  enc.maxBitrate = next;
1022
1033
  changed = true;
@@ -38,6 +38,12 @@ export class MediasoupManager extends EventEmitter {
38
38
  // no cap. See videoQualityProfiles.js + RemoteMediaManager.setLayerCeiling.
39
39
  this.layerCeiling = { maxSpatialLayer: null, maxTemporalLayer: null };
40
40
 
41
+ // Active send bitrate cap (bps) from the data-saver profile, or 0 when
42
+ // uncapped. QualityMonitor reads this so it can ignore Chrome's BWE
43
+ // "available upload" floor while we're INTENTIONALLY sending low — a low
44
+ // estimate then means "no demand to probe higher", not "bad network".
45
+ this.sendBitrateCap = 0;
46
+
41
47
  // Initialize stats collector
42
48
  this.statsCollector = new StatsCollector(this.logger);
43
49
  this.virtualBackgroundStore = null; // Will be set externally
@@ -674,27 +680,40 @@ export class MediasoupManager extends EventEmitter {
674
680
  });
675
681
  }
676
682
 
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.)
683
+ // HARD bitrate ceiling (data-saver / quality level). On a metered link
684
+ // the profile's sendMaxBitrate is a budget the encoder must not exceed
685
+ // even during high motion, when resolution/fps caps alone let the
686
+ // rate spike. With simulcast, total bandwidth is the SUM of all active
687
+ // layers, so the budget is split across the layers present such that
688
+ // the layers SUM to the budget (not each capped at the full budget —
689
+ // that earlier bug let the total reach ~1.6× the cap). The top layer
690
+ // gets the lion's share; lower layers get proportionally less.
691
+ // (Screen share never reaches here.)
684
692
  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);
693
+ // Remember the active send cap so QualityMonitor can discount the BWE
694
+ // floor while we're intentionally throttled (data saver).
695
+ this.sendBitrateCap = maxBitrate;
696
+ if (maxBitrate > 0 && produceOptions.encodings?.length) {
697
+ // Relative weights per rid; we normalize over the layers actually
698
+ // present so the allocated total always equals the budget whether
699
+ // there are 1, 2, or 3 layers.
700
+ const WEIGHT = { l: 0.15, m: 0.35, h: 0.65 };
701
+ const encs = produceOptions.encodings;
702
+ const totalWeight = encs.reduce(
703
+ (sum, e) => sum + (WEIGHT[e.rid] ?? 1.0),
704
+ 0,
705
+ );
706
+ for (const enc of encs) {
707
+ const w = WEIGHT[enc.rid] ?? 1.0;
708
+ const share = Math.round((maxBitrate * w) / totalWeight);
692
709
  // Only ever lower — never raise a layer above its tuned value.
693
- enc.maxBitrate = Math.min(enc.maxBitrate || ceiling, ceiling);
710
+ enc.maxBitrate = Math.min(enc.maxBitrate || share, share);
694
711
  }
712
+ const allocated = encs.reduce((s, e) => s + (e.maxBitrate || 0), 0);
695
713
  this.logger.info("VIDEO_QUALITY :: applied hard bitrate ceiling", {
696
- maxBitrate: `${(maxBitrate / 1000).toFixed(0)}kbps`,
697
- encodings: produceOptions.encodings.map(
714
+ budget: `${(maxBitrate / 1000).toFixed(0)}kbps`,
715
+ allocatedTotal: `${(allocated / 1000).toFixed(0)}kbps`,
716
+ encodings: encs.map(
698
717
  (e) => `${e.rid}: ${(e.maxBitrate / 1000).toFixed(0)}kbps`,
699
718
  ),
700
719
  });
@@ -1091,6 +1110,21 @@ export class MediasoupManager extends EventEmitter {
1091
1110
  return this.producers.get("video") || this.producers.get("camera") || null;
1092
1111
  }
1093
1112
 
1113
+ /**
1114
+ * Set/clear the active send bitrate cap (bps; 0 = uncapped). Lets the
1115
+ * mid-call data-saver path (setParameters, no re-produce) keep
1116
+ * sendBitrateCap in sync so QualityMonitor discounts the BWE floor.
1117
+ * @param {number} bps
1118
+ */
1119
+ setSendBitrateCap(bps) {
1120
+ this.sendBitrateCap = Number(bps) || 0;
1121
+ }
1122
+
1123
+ /** @returns {number} active send bitrate cap in bps (0 = uncapped) */
1124
+ getSendBitrateCap() {
1125
+ return this.sendBitrateCap || 0;
1126
+ }
1127
+
1094
1128
  /**
1095
1129
  * Ask the SFU to forward a lower simulcast layer of the given peer's
1096
1130
  * video producer to us. spatialLayer 0=low, 1=mid, 2=high.
@@ -423,15 +423,27 @@ export class QualityMonitor {
423
423
  // counting that as a bad network wrongly marks audio-only participants
424
424
  // "poor" and triggers a disable-camera suggestion for a camera that's
425
425
  // already off. RTT + loss remain valid either way.
426
- // Only count availMbps once we've seen a real reading (>0); some
427
- // browsers report 0 transiently at session start.
426
+ //
427
+ // The SAME artifact happens under data saver: when we deliberately cap the
428
+ // send to e.g. 600kbps and a low-motion scene only uses ~30kbps, the BWE
429
+ // collapses toward what we're actually sending (no demand to probe higher),
430
+ // so availMbps reads ~0.1 even on a perfectly healthy link. So when a send
431
+ // cap is active we IGNORE the availMbps floor entirely and rely only on the
432
+ // encoder-bandwidth-limited ratio, which genuinely means "the encoder wants
433
+ // more bits than the link will give" — the real signal that survives a cap.
434
+ const sendCapped =
435
+ typeof this.mediasoupManager?.getSendBitrateCap === "function" &&
436
+ this.mediasoupManager.getSendBitrateCap() > 0;
437
+ const useAvailFloor = this._isSendingVideo() && !sendCapped;
428
438
  const bandwidthSeverity = !this._isSendingVideo()
429
439
  ? 0
430
- : availMbps > 0 && availMbps <= this.thresholds.availMbpsCrit
440
+ : useAvailFloor && availMbps > 0 && availMbps <= this.thresholds.availMbpsCrit
431
441
  ? 2
432
442
  : bwLimitedRatio >= this.thresholds.encoderBwLimitedCritRatio
433
443
  ? 2
434
- : availMbps > 0 && availMbps <= this.thresholds.availMbpsWarn
444
+ : useAvailFloor &&
445
+ availMbps > 0 &&
446
+ availMbps <= this.thresholds.availMbpsWarn
435
447
  ? 1
436
448
  : bwLimitedRatio >= this.thresholds.encoderBwLimitedWarnRatio
437
449
  ? 1
@@ -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.10",
3
+ "version": "2.0.12",
4
4
  "description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
5
5
  "type": "module",
6
6
  "main": "index.js",