@unboundcx/video-sdk-client 2.0.10 → 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.
@@ -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;
@@ -674,27 +674,37 @@ export class MediasoupManager extends EventEmitter {
674
674
  });
675
675
  }
676
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.)
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.)
684
686
  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);
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);
692
700
  // Only ever lower — never raise a layer above its tuned value.
693
- enc.maxBitrate = Math.min(enc.maxBitrate || ceiling, ceiling);
701
+ enc.maxBitrate = Math.min(enc.maxBitrate || share, share);
694
702
  }
703
+ const allocated = encs.reduce((s, e) => s + (e.maxBitrate || 0), 0);
695
704
  this.logger.info("VIDEO_QUALITY :: applied hard bitrate ceiling", {
696
- maxBitrate: `${(maxBitrate / 1000).toFixed(0)}kbps`,
697
- encodings: produceOptions.encodings.map(
705
+ budget: `${(maxBitrate / 1000).toFixed(0)}kbps`,
706
+ allocatedTotal: `${(allocated / 1000).toFixed(0)}kbps`,
707
+ encodings: encs.map(
698
708
  (e) => `${e.rid}: ${(e.maxBitrate / 1000).toFixed(0)}kbps`,
699
709
  ),
700
710
  });
@@ -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.11",
4
4
  "description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
5
5
  "type": "module",
6
6
  "main": "index.js",