@unboundcx/video-sdk-client 2.0.9 → 2.0.10
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,6 +995,8 @@ 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
|
+
// Per-layer fraction of the bitrate budget — matches produce().
|
|
999
|
+
const LAYER_FRACTION = { l: 0.18, m: 0.4, h: 1.0 };
|
|
987
1000
|
|
|
988
1001
|
try {
|
|
989
1002
|
const params = sender.getParameters();
|
|
@@ -1000,12 +1013,27 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
1000
1013
|
enc.active = wantActive;
|
|
1001
1014
|
changed = true;
|
|
1002
1015
|
}
|
|
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);
|
|
1020
|
+
if (enc.maxBitrate !== next) {
|
|
1021
|
+
enc.maxBitrate = next;
|
|
1022
|
+
changed = true;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1003
1025
|
}
|
|
1004
1026
|
if (changed) {
|
|
1005
1027
|
sender.setParameters(params);
|
|
1006
1028
|
this.logger.info(
|
|
1007
1029
|
'LocalMediaManager :: _capSendSimulcastToResolution ::',
|
|
1008
|
-
{
|
|
1030
|
+
{
|
|
1031
|
+
sendResolution,
|
|
1032
|
+
capWidth,
|
|
1033
|
+
maxBitrate: maxBitrate
|
|
1034
|
+
? `${(maxBitrate / 1000).toFixed(0)}kbps`
|
|
1035
|
+
: 'none',
|
|
1036
|
+
}
|
|
1009
1037
|
);
|
|
1010
1038
|
}
|
|
1011
1039
|
} catch (err) {
|
|
@@ -673,6 +673,32 @@ export class MediasoupManager extends EventEmitter {
|
|
|
673
673
|
bitrate: "800kbps",
|
|
674
674
|
});
|
|
675
675
|
}
|
|
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.)
|
|
684
|
+
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);
|
|
692
|
+
// Only ever lower — never raise a layer above its tuned value.
|
|
693
|
+
enc.maxBitrate = Math.min(enc.maxBitrate || ceiling, ceiling);
|
|
694
|
+
}
|
|
695
|
+
this.logger.info("VIDEO_QUALITY :: applied hard bitrate ceiling", {
|
|
696
|
+
maxBitrate: `${(maxBitrate / 1000).toFixed(0)}kbps`,
|
|
697
|
+
encodings: produceOptions.encodings.map(
|
|
698
|
+
(e) => `${e.rid}: ${(e.maxBitrate / 1000).toFixed(0)}kbps`,
|
|
699
|
+
),
|
|
700
|
+
});
|
|
701
|
+
}
|
|
676
702
|
} // end camera-profile else (matches `if (isScreenShare)`)
|
|
677
703
|
}
|
|
678
704
|
|
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
|
},
|