@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.
package/VideoMeetingClient.js
CHANGED
|
@@ -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
|
-
//
|
|
999
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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).
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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 ||
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
//
|
|
427
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
)
|
|
461
|
-
|
|
462
|
-
videoStats.
|
|
463
|
-
|
|
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,
|