@unboundcx/video-sdk-client 2.0.8 → 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
|
|
|
@@ -11,6 +11,11 @@ export class StatsCollector {
|
|
|
11
11
|
this.tracks = new Map(); // trackId -> { participantId, kind }
|
|
12
12
|
this.onStatsCallback = null; // Host-app callback (UI display)
|
|
13
13
|
this._internalCallbacks = []; // SDK-internal subscribers (QualityMonitor)
|
|
14
|
+
// Previous cumulative byte counts + timestamp per transport, so we can
|
|
15
|
+
// turn the monotonic bytesSent/bytesReceived counters into an actual
|
|
16
|
+
// throughput RATE (kbps) instead of a meaningless running total. Keyed by
|
|
17
|
+
// transportType ('send' | 'recv'). See calculateBandwidth().
|
|
18
|
+
this._prevBytes = { send: null, recv: null };
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/**
|
|
@@ -214,37 +219,60 @@ export class StatsCollector {
|
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
/**
|
|
217
|
-
* Calculate
|
|
222
|
+
* Calculate actual throughput from stats.
|
|
223
|
+
*
|
|
224
|
+
* bytesSent / bytesReceived are MONOTONIC cumulative counters (total since
|
|
225
|
+
* the connection started), so summing them yields a meaningless ever-growing
|
|
226
|
+
* number. To get a real RATE we diff the current total against the previous
|
|
227
|
+
* sample and divide by the elapsed time. Returns both kbps (for display) and
|
|
228
|
+
* Mbps (back-compat with existing consumers). The first sample per transport
|
|
229
|
+
* has no baseline, so its rate is 0 until the next cycle establishes a delta.
|
|
230
|
+
*
|
|
231
|
+
* `upload`/`download` (Mbps) are kept for back-compat; `uploadKbps`/
|
|
232
|
+
* `downloadKbps` are the values the quality panel shows.
|
|
218
233
|
*/
|
|
219
234
|
calculateBandwidth(stats, transportType) {
|
|
220
235
|
const bandwidth = {
|
|
221
|
-
upload: 0,
|
|
222
|
-
download: 0,
|
|
236
|
+
upload: 0, // Mbps (back-compat)
|
|
237
|
+
download: 0, // Mbps (back-compat)
|
|
238
|
+
uploadKbps: 0,
|
|
239
|
+
downloadKbps: 0,
|
|
223
240
|
unit: 'Mbps',
|
|
224
241
|
};
|
|
225
242
|
|
|
243
|
+
// Sum cumulative bytes across all RTP streams for this transport, and take
|
|
244
|
+
// the freshest report timestamp as "now" (RTCStats timestamps are in ms).
|
|
245
|
+
let totalBytes = 0;
|
|
246
|
+
let ts = 0;
|
|
247
|
+
const wantType = transportType === 'send' ? 'outbound-rtp' : 'inbound-rtp';
|
|
248
|
+
const byteField = transportType === 'send' ? 'bytesSent' : 'bytesReceived';
|
|
226
249
|
for (const report of stats.values()) {
|
|
250
|
+
if (report.type !== wantType) continue;
|
|
251
|
+
totalBytes += Number(report[byteField]) || 0;
|
|
252
|
+
if (report.timestamp && report.timestamp > ts) ts = report.timestamp;
|
|
253
|
+
}
|
|
254
|
+
if (!ts) ts = Date.now();
|
|
255
|
+
|
|
256
|
+
const prev = this._prevBytes[transportType];
|
|
257
|
+
this._prevBytes[transportType] = { bytes: totalBytes, ts };
|
|
258
|
+
|
|
259
|
+
// Need a prior sample to compute a rate. Guard against counter resets
|
|
260
|
+
// (transport recreated → bytes drop) and non-positive elapsed time.
|
|
261
|
+
if (prev && totalBytes >= prev.bytes && ts > prev.ts) {
|
|
262
|
+
const deltaBits = (totalBytes - prev.bytes) * 8;
|
|
263
|
+
const deltaSec = (ts - prev.ts) / 1000;
|
|
264
|
+
const bps = deltaBits / deltaSec;
|
|
265
|
+
const kbps = parseFloat((bps / 1000).toFixed(1));
|
|
266
|
+
const mbps = parseFloat((bps / 1_000_000).toFixed(2));
|
|
227
267
|
if (transportType === 'send') {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (report.bytesSent) {
|
|
231
|
-
bandwidth.upload += report.bytesSent * 8; // Convert to bits
|
|
232
|
-
}
|
|
233
|
-
}
|
|
268
|
+
bandwidth.uploadKbps = kbps;
|
|
269
|
+
bandwidth.upload = mbps;
|
|
234
270
|
} else {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (report.bytesReceived) {
|
|
238
|
-
bandwidth.download += report.bytesReceived * 8; // Convert to bits
|
|
239
|
-
}
|
|
240
|
-
}
|
|
271
|
+
bandwidth.downloadKbps = kbps;
|
|
272
|
+
bandwidth.download = mbps;
|
|
241
273
|
}
|
|
242
274
|
}
|
|
243
275
|
|
|
244
|
-
// Convert to Mbps
|
|
245
|
-
bandwidth.upload = parseFloat((bandwidth.upload / 1000000).toFixed(2));
|
|
246
|
-
bandwidth.download = parseFloat((bandwidth.download / 1000000).toFixed(2));
|
|
247
|
-
|
|
248
276
|
return bandwidth;
|
|
249
277
|
}
|
|
250
278
|
|
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
|
},
|