@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.
package/VideoMeetingClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
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
|
-
{
|
|
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
|
-
|
|
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,
|
package/package.json
CHANGED
package/videoQualityProfiles.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
},
|