@unboundcx/video-sdk-client 2.0.6 → 2.0.8
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 +106 -1
- package/index.js +8 -0
- package/managers/ConnectionHealthMonitor.js +21 -2
- package/managers/LocalMediaManager.js +197 -3
- package/managers/MediasoupManager.js +56 -9
- package/managers/QualityMonitor.js +31 -7
- package/managers/RemoteMediaManager.js +118 -11
- package/managers/StatsCollector.js +17 -0
- package/package.json +2 -1
- package/videoQualityProfiles.js +100 -0
package/VideoMeetingClient.js
CHANGED
|
@@ -7,6 +7,11 @@ import { RemoteMediaManager } from "./managers/RemoteMediaManager.js";
|
|
|
7
7
|
import { QualityMonitor } from "./managers/QualityMonitor.js";
|
|
8
8
|
import { ConnectionHealthMonitor } from "./managers/ConnectionHealthMonitor.js";
|
|
9
9
|
import { StateError, RoomError } from "./utils/errors.js";
|
|
10
|
+
import {
|
|
11
|
+
getVideoQualityProfile,
|
|
12
|
+
sendConfigChanged,
|
|
13
|
+
DEFAULT_VIDEO_QUALITY_LEVEL,
|
|
14
|
+
} from "./videoQualityProfiles.js";
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
17
|
* Main SDK client for video meeting functionality
|
|
@@ -43,6 +48,7 @@ export class VideoMeetingClient extends EventEmitter {
|
|
|
43
48
|
this.state = "disconnected"; // disconnected, connecting, connected, waiting-room, in-meeting
|
|
44
49
|
this.currentRoomId = null;
|
|
45
50
|
this.joinData = null; // Store video.join() response data
|
|
51
|
+
this.serverInfo = null; // Handling pod identity (serverId/externalIp/geoLocation)
|
|
46
52
|
this.debug = options.debug;
|
|
47
53
|
this.isGuest = false;
|
|
48
54
|
this.inWaitingRoom = false;
|
|
@@ -62,6 +68,11 @@ export class VideoMeetingClient extends EventEmitter {
|
|
|
62
68
|
this.localMedia = null;
|
|
63
69
|
this.remoteMedia = null;
|
|
64
70
|
|
|
71
|
+
// Current video quality level (data-saver). 'auto' by default; the host
|
|
72
|
+
// app maps its "Data saver" toggle to 'medium'/'auto'. See
|
|
73
|
+
// videoQualityProfiles.js + setVideoQuality().
|
|
74
|
+
this._videoQualityLevel = DEFAULT_VIDEO_QUALITY_LEVEL;
|
|
75
|
+
|
|
65
76
|
// If serverUrl provided, initialize managers now (old behavior)
|
|
66
77
|
if (options.serverUrl) {
|
|
67
78
|
this._initializeManagers(options.serverUrl);
|
|
@@ -597,6 +608,13 @@ export class VideoMeetingClient extends EventEmitter {
|
|
|
597
608
|
// Listen for media.routerCapabilities to know when room is ready
|
|
598
609
|
this.connection.onServerEvent("media.routerCapabilities", (data) => {
|
|
599
610
|
this.logger.info("Room is ready - received media.routerCapabilities");
|
|
611
|
+
// The handling pod stamps its identity (serverId / externalIp /
|
|
612
|
+
// geoLocation) onto this message — surface it so the app can show
|
|
613
|
+
// which server is serving this participant.
|
|
614
|
+
if (data?.serverInfo) {
|
|
615
|
+
this.serverInfo = data.serverInfo;
|
|
616
|
+
this.emit("server:info", data.serverInfo);
|
|
617
|
+
}
|
|
600
618
|
this.emit("waitingRoom:ready", {
|
|
601
619
|
roomId: videoRoom.id,
|
|
602
620
|
participant,
|
|
@@ -757,7 +775,87 @@ export class VideoMeetingClient extends EventEmitter {
|
|
|
757
775
|
*/
|
|
758
776
|
async publishCamera(options = {}) {
|
|
759
777
|
this._ensureInRoom();
|
|
760
|
-
|
|
778
|
+
// Honor the active quality level so a camera (re)published after the user
|
|
779
|
+
// set data-saver — e.g. unmuting mid-call — comes up already capped.
|
|
780
|
+
const profile = getVideoQualityProfile(this._videoQualityLevel);
|
|
781
|
+
const merged = {
|
|
782
|
+
resolution: profile.sendResolution || options.resolution,
|
|
783
|
+
maxResolution: profile.sendResolution || options.maxResolution,
|
|
784
|
+
frameRate: profile.sendFrameRate || options.frameRate,
|
|
785
|
+
...options,
|
|
786
|
+
};
|
|
787
|
+
// options passed by the caller still win for explicit overrides, except
|
|
788
|
+
// we always apply the quality-level send cap on top.
|
|
789
|
+
if (profile.sendResolution) {
|
|
790
|
+
merged.resolution = profile.sendResolution;
|
|
791
|
+
merged.maxResolution = profile.sendResolution;
|
|
792
|
+
merged.frameRate = profile.sendFrameRate;
|
|
793
|
+
}
|
|
794
|
+
return await this.localMedia.publishCamera(merged);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Set the video quality level (data-saver / quality tiers). One call fans
|
|
799
|
+
* out to both directions:
|
|
800
|
+
* - receive: sets a shared layer ceiling on the mediasoup + remote media
|
|
801
|
+
* managers so we never pull above the cap from any peer (coordinates
|
|
802
|
+
* with QualityMonitor — the lower of {auto-selected, cap} always wins).
|
|
803
|
+
* - send: re-caps the live camera's capture resolution/frame-rate +
|
|
804
|
+
* simulcast top layer, without a full republish (low flicker). Skipped
|
|
805
|
+
* when the send config is unchanged (e.g. auto → high).
|
|
806
|
+
*
|
|
807
|
+
* Screen share is intentionally untouched in both directions.
|
|
808
|
+
*
|
|
809
|
+
* @param {'auto'|'high'|'medium'|'low'} level
|
|
810
|
+
* @returns {Promise<void>}
|
|
811
|
+
*/
|
|
812
|
+
async setVideoQuality(level) {
|
|
813
|
+
const profile = getVideoQualityProfile(level);
|
|
814
|
+
const prevLevel = this._videoQualityLevel;
|
|
815
|
+
this._videoQualityLevel = level;
|
|
816
|
+
this.logger.info("VideoMeetingClient :: setVideoQuality ::", {
|
|
817
|
+
level,
|
|
818
|
+
profile,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// ---- receive ceiling (applies immediately to existing consumers) ----
|
|
822
|
+
if (this.mediasoup) {
|
|
823
|
+
this.mediasoup.setLayerCeiling(
|
|
824
|
+
profile.maxSpatialLayer,
|
|
825
|
+
profile.maxTemporalLayer,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (this.remoteMedia) {
|
|
829
|
+
this.remoteMedia.setLayerCeiling(
|
|
830
|
+
profile.maxSpatialLayer,
|
|
831
|
+
profile.maxTemporalLayer,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ---- send cap (only if the capture config actually changed) ----
|
|
836
|
+
if (
|
|
837
|
+
sendConfigChanged(prevLevel, level) &&
|
|
838
|
+
this.localMedia?.isCameraActive
|
|
839
|
+
) {
|
|
840
|
+
try {
|
|
841
|
+
await this.localMedia.setSendQuality(profile);
|
|
842
|
+
} catch (err) {
|
|
843
|
+
this.logger.error(
|
|
844
|
+
"VideoMeetingClient :: setVideoQuality :: send cap failed",
|
|
845
|
+
err,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
this.emit("quality:level", { level });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Current video quality level.
|
|
855
|
+
* @returns {'auto'|'high'|'medium'|'low'}
|
|
856
|
+
*/
|
|
857
|
+
getVideoQualityLevel() {
|
|
858
|
+
return this._videoQualityLevel;
|
|
761
859
|
}
|
|
762
860
|
|
|
763
861
|
/**
|
|
@@ -1222,6 +1320,13 @@ export class VideoMeetingClient extends EventEmitter {
|
|
|
1222
1320
|
const oldState = this.state;
|
|
1223
1321
|
this.state = newState;
|
|
1224
1322
|
this.logger.info(`State changed: ${oldState} -> ${newState}`);
|
|
1323
|
+
// Tell the health monitor whether media should be flowing. Media silence
|
|
1324
|
+
// only signals a problem once we're actually in the meeting; before that
|
|
1325
|
+
// (connecting / waiting-room) there's no media by design, so silence must
|
|
1326
|
+
// not escalate to "Connection lost". Socket/transport failures still do.
|
|
1327
|
+
if (this.connectionHealth?.setMediaExpected) {
|
|
1328
|
+
this.connectionHealth.setMediaExpected(newState === "in-meeting");
|
|
1329
|
+
}
|
|
1225
1330
|
this.emit("state:changed", { from: oldState, to: newState });
|
|
1226
1331
|
}
|
|
1227
1332
|
|
package/index.js
CHANGED
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
export { VideoMeetingClient } from './VideoMeetingClient.js';
|
|
19
19
|
export { VideoProcessor } from './VideoProcessor.js';
|
|
20
20
|
|
|
21
|
+
// Video quality tiers (data-saver / quality level)
|
|
22
|
+
export {
|
|
23
|
+
VIDEO_QUALITY_LEVELS,
|
|
24
|
+
VIDEO_QUALITY_PROFILES,
|
|
25
|
+
DEFAULT_VIDEO_QUALITY_LEVEL,
|
|
26
|
+
getVideoQualityProfile,
|
|
27
|
+
} from './videoQualityProfiles.js';
|
|
28
|
+
|
|
21
29
|
// Managers (for advanced usage)
|
|
22
30
|
export { ConnectionManager } from './managers/ConnectionManager.js';
|
|
23
31
|
export { MediasoupManager } from './managers/MediasoupManager.js';
|
|
@@ -74,6 +74,11 @@ export class ConnectionHealthMonitor {
|
|
|
74
74
|
this._timer = null;
|
|
75
75
|
this._started = false;
|
|
76
76
|
this._unsubs = [];
|
|
77
|
+
// Whether media should be flowing. Until we're in the meeting (set by
|
|
78
|
+
// VideoMeetingClient via setMediaExpected), media silence is expected
|
|
79
|
+
// and must not escalate the state — the pre-join waiting room is quiet
|
|
80
|
+
// by design. Socket/transport failures still escalate regardless.
|
|
81
|
+
this._mediaExpected = false;
|
|
77
82
|
|
|
78
83
|
// Track transport-level state independently of socket-level state so
|
|
79
84
|
// we can report the right reason in the event payload.
|
|
@@ -107,6 +112,13 @@ export class ConnectionHealthMonitor {
|
|
|
107
112
|
// (any socket message, any stats sample). The wiring below already
|
|
108
113
|
// does this for the common cases — exposed for hosts that want to
|
|
109
114
|
// mark explicit liveness pings.
|
|
115
|
+
// Public: toggled by VideoMeetingClient when entering/leaving the meeting.
|
|
116
|
+
// Resets the silence clock on enable so the timer starts fresh at join.
|
|
117
|
+
setMediaExpected(expected) {
|
|
118
|
+
this._mediaExpected = !!expected;
|
|
119
|
+
if (this._mediaExpected) this._lastActivityAt = Date.now();
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
markActivity() {
|
|
111
123
|
this._lastActivityAt = Date.now();
|
|
112
124
|
// Hearing from the server is sufficient evidence the connection is
|
|
@@ -226,12 +238,19 @@ export class ConnectionHealthMonitor {
|
|
|
226
238
|
// has run long past Socket.IO's own ping timeout (the engine itself
|
|
227
239
|
// is quiet, which is real evidence). This prevents an idle-but-fine
|
|
228
240
|
// session (e.g. host alone in the room) from cascading to "failed".
|
|
241
|
+
// Silence only counts as a problem once media should be flowing. Before
|
|
242
|
+
// that (waiting room / pre-join) it's expected, so don't escalate on it.
|
|
243
|
+
const silenceCounts = this._mediaExpected;
|
|
229
244
|
const hasCorroboration =
|
|
230
245
|
!this._socketAlive ||
|
|
231
246
|
this._transportFailed() ||
|
|
232
|
-
silence >= this.opts.silenceReconnectingMs;
|
|
247
|
+
(silenceCounts && silence >= this.opts.silenceReconnectingMs);
|
|
233
248
|
|
|
234
|
-
if (
|
|
249
|
+
if (
|
|
250
|
+
this._state === 'connected' &&
|
|
251
|
+
silenceCounts &&
|
|
252
|
+
silence >= this.opts.silenceUnstableMs
|
|
253
|
+
) {
|
|
235
254
|
this._transition('unstable', `silence:${silence}ms`);
|
|
236
255
|
} else if (this._state === 'unstable' && hasCorroboration) {
|
|
237
256
|
this._transition(
|
|
@@ -75,10 +75,46 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
75
75
|
async getDevices() {
|
|
76
76
|
this.logger.log('Getting available devices');
|
|
77
77
|
|
|
78
|
+
// Request permission so enumerateDevices() returns device labels. Ask
|
|
79
|
+
// for audio and video INDEPENDENTLY — a single denied/missing device
|
|
80
|
+
// (e.g. camera blocked but microphone allowed) must not throw away the
|
|
81
|
+
// whole list. These temporary tracks exist only to unlock labels, so we
|
|
82
|
+
// stop them immediately; the published tracks are separate instances.
|
|
83
|
+
const stopTracks = (stream) => {
|
|
84
|
+
try {
|
|
85
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
86
|
+
} catch (e) {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
};
|
|
78
90
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
stopTracks(
|
|
92
|
+
await navigator.mediaDevices.getUserMedia({ audio: true, video: true }),
|
|
93
|
+
);
|
|
94
|
+
} catch (combinedError) {
|
|
95
|
+
this.logger.log(
|
|
96
|
+
'getDevices :: combined permission request failed, trying audio and video separately ::',
|
|
97
|
+
combinedError?.name,
|
|
98
|
+
);
|
|
99
|
+
try {
|
|
100
|
+
stopTracks(await navigator.mediaDevices.getUserMedia({ audio: true }));
|
|
101
|
+
} catch (audioError) {
|
|
102
|
+
this.logger.log(
|
|
103
|
+
'getDevices :: microphone unavailable ::',
|
|
104
|
+
audioError?.name,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
stopTracks(await navigator.mediaDevices.getUserMedia({ video: true }));
|
|
109
|
+
} catch (videoError) {
|
|
110
|
+
this.logger.log(
|
|
111
|
+
'getDevices :: camera unavailable ::',
|
|
112
|
+
videoError?.name,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
81
116
|
|
|
117
|
+
try {
|
|
82
118
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
83
119
|
|
|
84
120
|
const result = {
|
|
@@ -95,7 +131,7 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
95
131
|
|
|
96
132
|
return result;
|
|
97
133
|
} catch (error) {
|
|
98
|
-
this.logger.error('Failed to
|
|
134
|
+
this.logger.error('Failed to enumerate devices:', error);
|
|
99
135
|
throw wrapError(error, 'getDevices');
|
|
100
136
|
}
|
|
101
137
|
}
|
|
@@ -824,6 +860,162 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
824
860
|
}
|
|
825
861
|
}
|
|
826
862
|
|
|
863
|
+
/**
|
|
864
|
+
* Apply a send-side quality cap (data-saver / quality level) to the live
|
|
865
|
+
* camera WITHOUT a full republish/renegotiation. Two coordinated moves:
|
|
866
|
+
*
|
|
867
|
+
* 1. Re-acquire the camera at the capped capture resolution + frame
|
|
868
|
+
* rate and replaceTrack() it into the existing producer. Lowering
|
|
869
|
+
* actual capture is what saves CPU, battery and capture-side
|
|
870
|
+
* bandwidth — disabling a simulcast layer alone keeps encoding the
|
|
871
|
+
* full-res frame.
|
|
872
|
+
* 2. Cap the simulcast top layer via setParameters (deactivate
|
|
873
|
+
* encodings whose source res now exceeds the cap), so we don't pay
|
|
874
|
+
* 1080p-tier bits to ship a 540p picture.
|
|
875
|
+
*
|
|
876
|
+
* Same device, same background, no transport renegotiation → far less
|
|
877
|
+
* visible than tearing the producer down and re-publishing. No-op if the
|
|
878
|
+
* camera isn't currently active.
|
|
879
|
+
*
|
|
880
|
+
* @param {Object} profile - from videoQualityProfiles.js
|
|
881
|
+
* @param {string|null} profile.sendResolution - '360p'|'540p'|'720p'|'1080p'|null
|
|
882
|
+
* @param {number} profile.sendFrameRate
|
|
883
|
+
* @returns {Promise<void>}
|
|
884
|
+
*/
|
|
885
|
+
async setSendQuality(profile = {}) {
|
|
886
|
+
if (!this.producers.video || !this.streams.camera) {
|
|
887
|
+
this.logger.info(
|
|
888
|
+
'LocalMediaManager :: setSendQuality :: no active camera, skipping'
|
|
889
|
+
);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const oldProcessedStream = this.streams.camera;
|
|
894
|
+
const oldRawStream = this.rawCameraStream;
|
|
895
|
+
|
|
896
|
+
try {
|
|
897
|
+
// Re-acquire at the capped capture constraints, reusing the current
|
|
898
|
+
// device/facingMode (cameraSource) so we don't switch cameras.
|
|
899
|
+
const reacquireOptions = {
|
|
900
|
+
...(this.cameraSource || {}),
|
|
901
|
+
resolution: profile.sendResolution || undefined,
|
|
902
|
+
frameRate: profile.sendFrameRate || undefined,
|
|
903
|
+
};
|
|
904
|
+
const constraints = this._getVideoConstraints(reacquireOptions);
|
|
905
|
+
this.logger.info(
|
|
906
|
+
'LocalMediaManager :: setSendQuality :: re-acquiring camera',
|
|
907
|
+
{ sendResolution: profile.sendResolution, frameRate: profile.sendFrameRate }
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
const rawStream = await navigator.mediaDevices.getUserMedia({
|
|
911
|
+
video: constraints,
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
let stream = rawStream;
|
|
915
|
+
if (this.currentBackgroundOptions) {
|
|
916
|
+
try {
|
|
917
|
+
await this.videoProcessor.cleanup();
|
|
918
|
+
} catch (cleanupErr) {
|
|
919
|
+
this.logger.warn(
|
|
920
|
+
'LocalMediaManager :: setSendQuality :: processor cleanup failed',
|
|
921
|
+
cleanupErr
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
stream = await this.videoProcessor.applyBackground(
|
|
925
|
+
rawStream,
|
|
926
|
+
this.currentBackgroundOptions
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const newTrack = stream.getVideoTracks()[0];
|
|
931
|
+
await this.producers.video.replaceTrack({ track: newTrack });
|
|
932
|
+
this._watchCameraTrack(newTrack);
|
|
933
|
+
|
|
934
|
+
// Stop the old streams now that the new track is live.
|
|
935
|
+
if (oldRawStream && oldRawStream !== rawStream) {
|
|
936
|
+
oldRawStream.getTracks().forEach((t) => t.stop());
|
|
937
|
+
}
|
|
938
|
+
if (
|
|
939
|
+
oldProcessedStream &&
|
|
940
|
+
oldProcessedStream !== oldRawStream &&
|
|
941
|
+
oldProcessedStream !== stream
|
|
942
|
+
) {
|
|
943
|
+
oldProcessedStream.getTracks().forEach((t) => t.stop());
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
this.streams.camera = stream;
|
|
947
|
+
this.rawCameraStream = rawStream;
|
|
948
|
+
|
|
949
|
+
// Cap the simulcast top layer to match the new capture resolution.
|
|
950
|
+
this._capSendSimulcastToResolution(profile.sendResolution);
|
|
951
|
+
|
|
952
|
+
this.logger.info('LocalMediaManager :: setSendQuality :: applied', {
|
|
953
|
+
resolution: `${newTrack.getSettings().width}x${newTrack.getSettings().height}`,
|
|
954
|
+
frameRate: newTrack.getSettings().frameRate,
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
this.emit('stream:added', {
|
|
958
|
+
type: 'camera',
|
|
959
|
+
stream,
|
|
960
|
+
producer: this.producers.video,
|
|
961
|
+
});
|
|
962
|
+
} catch (error) {
|
|
963
|
+
this.logger.error(
|
|
964
|
+
'LocalMediaManager :: setSendQuality :: failed',
|
|
965
|
+
error
|
|
966
|
+
);
|
|
967
|
+
throw wrapError(error, 'setSendQuality');
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Deactivate simulcast encodings whose source resolution exceeds the cap,
|
|
973
|
+
* via RTCRtpSender.setParameters (no renegotiation). null/'1080p' = no cap
|
|
974
|
+
* (reactivate everything). Mirrors QualityMonitor's outbound capping but
|
|
975
|
+
* keyed off a user-chosen resolution rather than a 'mid'|'low' target.
|
|
976
|
+
* @private
|
|
977
|
+
* @param {string|null} sendResolution
|
|
978
|
+
*/
|
|
979
|
+
_capSendSimulcastToResolution(sendResolution) {
|
|
980
|
+
const sender = this.producers.video?.rtpSender;
|
|
981
|
+
if (!sender || typeof sender.getParameters !== 'function') return;
|
|
982
|
+
|
|
983
|
+
// rid → approx source width at 1080p capture (l=1/4, m=1/2, h=full).
|
|
984
|
+
// Keep a layer active only if its width is within the cap.
|
|
985
|
+
const capWidth =
|
|
986
|
+
{ '360p': 640, '540p': 960, '720p': 1280 }[sendResolution] || Infinity;
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
const params = sender.getParameters();
|
|
990
|
+
if (!params.encodings || params.encodings.length < 2) return;
|
|
991
|
+
let changed = false;
|
|
992
|
+
for (const enc of params.encodings) {
|
|
993
|
+
// scaleResolutionDownBy tells us this layer's output width
|
|
994
|
+
// 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
|
+
const layerWidth = captureWidth / (enc.scaleResolutionDownBy || 1);
|
|
998
|
+
const wantActive = layerWidth <= capWidth + 1; // +1 for rounding
|
|
999
|
+
if (enc.active !== wantActive) {
|
|
1000
|
+
enc.active = wantActive;
|
|
1001
|
+
changed = true;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if (changed) {
|
|
1005
|
+
sender.setParameters(params);
|
|
1006
|
+
this.logger.info(
|
|
1007
|
+
'LocalMediaManager :: _capSendSimulcastToResolution ::',
|
|
1008
|
+
{ sendResolution, capWidth }
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
this.logger.warn(
|
|
1013
|
+
'LocalMediaManager :: _capSendSimulcastToResolution failed',
|
|
1014
|
+
err
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
827
1019
|
/**
|
|
828
1020
|
* Update background effect on active camera
|
|
829
1021
|
* @param {Object} options - Background options
|
|
@@ -1090,7 +1282,9 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
1090
1282
|
*/
|
|
1091
1283
|
_getVideoConstraints(options = {}) {
|
|
1092
1284
|
const resolutions = {
|
|
1285
|
+
'360p': { width: 640, height: 360 },
|
|
1093
1286
|
'480p': { width: 640, height: 480 },
|
|
1287
|
+
'540p': { width: 960, height: 540 },
|
|
1094
1288
|
'720p': { width: 1280, height: 720 },
|
|
1095
1289
|
'1080p': { width: 1920, height: 1080 },
|
|
1096
1290
|
};
|
|
@@ -33,6 +33,11 @@ export class MediasoupManager extends EventEmitter {
|
|
|
33
33
|
this.producers = new Map(); // Map<type, Producer>
|
|
34
34
|
this.consumers = new Map(); // Map<consumerId, Consumer>
|
|
35
35
|
|
|
36
|
+
// Receive-side quality ceiling (data-saver / quality level). Enforced in
|
|
37
|
+
// setPeerPreferredLayer so every inbound-layer request is bounded. null =
|
|
38
|
+
// no cap. See videoQualityProfiles.js + RemoteMediaManager.setLayerCeiling.
|
|
39
|
+
this.layerCeiling = { maxSpatialLayer: null, maxTemporalLayer: null };
|
|
40
|
+
|
|
36
41
|
// Initialize stats collector
|
|
37
42
|
this.statsCollector = new StatsCollector(this.logger);
|
|
38
43
|
this.virtualBackgroundStore = null; // Will be set externally
|
|
@@ -580,19 +585,32 @@ export class MediasoupManager extends EventEmitter {
|
|
|
580
585
|
// No `return` — we still want to call transport.produce().
|
|
581
586
|
} else {
|
|
582
587
|
|
|
583
|
-
// Check if user has set a max resolution preference
|
|
588
|
+
// Check if user has set a max resolution preference (data-saver /
|
|
589
|
+
// quality level). The top simulcast layer is capped to this; lower
|
|
590
|
+
// layers scale from it. Bitrate budget tracks the cap so we don't
|
|
591
|
+
// pay 1080p bits to send a 540p top layer.
|
|
584
592
|
const maxResolution = options.maxResolution || "1080p"; // Default to 1080p
|
|
585
593
|
|
|
594
|
+
// Resolution caps → { width, bitrate } for the top (h) layer. Only
|
|
595
|
+
// applied when the captured track is actually larger than the cap.
|
|
596
|
+
const RES_CAPS = {
|
|
597
|
+
"360p": { width: 640, bitrate: 400000 },
|
|
598
|
+
"540p": { width: 960, bitrate: 1000000 },
|
|
599
|
+
"720p": { width: 1280, bitrate: 2000000 },
|
|
600
|
+
"1080p": { width: 1920, bitrate: 3500000 },
|
|
601
|
+
};
|
|
602
|
+
|
|
586
603
|
// Calculate target resolution based on user preference
|
|
587
604
|
let targetWidth = baseWidth;
|
|
588
605
|
let targetHeight = baseHeight;
|
|
589
606
|
let targetBitrate = 3500000;
|
|
590
607
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
targetHeight =
|
|
595
|
-
|
|
608
|
+
const cap = RES_CAPS[maxResolution];
|
|
609
|
+
if (cap && baseWidth > cap.width) {
|
|
610
|
+
// Scale the top layer down to the cap, preserving aspect ratio.
|
|
611
|
+
targetHeight = Math.round((baseHeight * cap.width) / baseWidth);
|
|
612
|
+
targetWidth = cap.width;
|
|
613
|
+
targetBitrate = cap.bitrate;
|
|
596
614
|
}
|
|
597
615
|
|
|
598
616
|
// Adaptive simulcast configuration based on target resolution
|
|
@@ -855,8 +873,13 @@ export class MediasoupManager extends EventEmitter {
|
|
|
855
873
|
producer.close();
|
|
856
874
|
this.producers.delete(type);
|
|
857
875
|
|
|
858
|
-
// Notify server
|
|
859
|
-
|
|
876
|
+
// Notify server. Event name is `media.produceClose` (camelCase
|
|
877
|
+
// suffix) — must match the handler registration in app1-socket's
|
|
878
|
+
// /video routes and app1-video-server's NATS handler map. A dotted
|
|
879
|
+
// variant ("media.produce.close") gets silently dropped server-side
|
|
880
|
+
// and the SDK then times out after 5s with the producer still
|
|
881
|
+
// active on the SFU — remote consumers see a frozen frame.
|
|
882
|
+
await this.connection.request("media.produceClose", {
|
|
860
883
|
producer: {
|
|
861
884
|
id: producer.id,
|
|
862
885
|
producerType: type,
|
|
@@ -1055,6 +1078,20 @@ export class MediasoupManager extends EventEmitter {
|
|
|
1055
1078
|
*/
|
|
1056
1079
|
setPeerPreferredLayer(peerParticipantId, spatialLayer) {
|
|
1057
1080
|
if (!this.connection?.socket) return false;
|
|
1081
|
+
// Apply the user's quality ceiling (data-saver). This is the single
|
|
1082
|
+
// chokepoint every layer request flows through — the resize
|
|
1083
|
+
// auto-selector AND QualityMonitor's inbound uncap (which asks for
|
|
1084
|
+
// spatialLayer 2) both land here — so clamping once keeps every caller
|
|
1085
|
+
// bounded. The lower of {requested, ceiling} always wins.
|
|
1086
|
+
const spatial =
|
|
1087
|
+
this.layerCeiling.maxSpatialLayer == null
|
|
1088
|
+
? spatialLayer
|
|
1089
|
+
: Math.min(spatialLayer, this.layerCeiling.maxSpatialLayer);
|
|
1090
|
+
const temporal =
|
|
1091
|
+
this.layerCeiling.maxTemporalLayer == null
|
|
1092
|
+
? 2
|
|
1093
|
+
: Math.min(2, this.layerCeiling.maxTemporalLayer);
|
|
1094
|
+
|
|
1058
1095
|
// Find every consumer whose producer belongs to this peer. We may
|
|
1059
1096
|
// have a video + a screenshare consumer for the same peer; we cap
|
|
1060
1097
|
// only kind='video' to avoid clobbering screen share quality.
|
|
@@ -1064,13 +1101,23 @@ export class MediasoupManager extends EventEmitter {
|
|
|
1064
1101
|
if (consumer.appData?.participantId !== peerParticipantId) continue;
|
|
1065
1102
|
this.connection.socket.emit("media.consumer.setPreferredLayers", {
|
|
1066
1103
|
consumer: { id: consumer.id },
|
|
1067
|
-
preferredLayers: { spatialLayer, temporalLayer:
|
|
1104
|
+
preferredLayers: { spatialLayer: spatial, temporalLayer: temporal },
|
|
1068
1105
|
});
|
|
1069
1106
|
sent = true;
|
|
1070
1107
|
}
|
|
1071
1108
|
return sent;
|
|
1072
1109
|
}
|
|
1073
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* Set the receive-side quality ceiling enforced in setPeerPreferredLayer.
|
|
1113
|
+
* null for either value lifts that ceiling. Data-saver / quality level.
|
|
1114
|
+
* @param {number|null} maxSpatialLayer
|
|
1115
|
+
* @param {number|null} maxTemporalLayer
|
|
1116
|
+
*/
|
|
1117
|
+
setLayerCeiling(maxSpatialLayer, maxTemporalLayer) {
|
|
1118
|
+
this.layerCeiling = { maxSpatialLayer, maxTemporalLayer };
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1074
1121
|
/**
|
|
1075
1122
|
* Pause/resume the local consumer AND ask the SFU to stop/start
|
|
1076
1123
|
* forwarding bytes for this peer's video. Local pause alone would
|
|
@@ -30,11 +30,15 @@
|
|
|
30
30
|
// quality-monitor-thresholds.test.js for the user-visible reaction
|
|
31
31
|
// times these collectively produce.
|
|
32
32
|
const DEFAULTS = {
|
|
33
|
-
// SignalState gates — outer gate per level transition.
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
// SignalState gates — outer gate per level transition. Kept in line with
|
|
34
|
+
// the inner encoder gates (encoderConstrainedMs / encoderSevereConstrainedMs)
|
|
35
|
+
// and the user-visible reaction times asserted in
|
|
36
|
+
// quality-monitor-thresholds.test.js: a silent cap / warning within ~10s of
|
|
37
|
+
// sustained badness, critical within ~25s. Short enough to feel Meet/Zoom-
|
|
38
|
+
// fast, long enough that momentary BWE blips don't toast — the recovery hold
|
|
39
|
+
// + per-level toast cooldown below absorb flapping.
|
|
40
|
+
warnSustainedMs: 8000,
|
|
41
|
+
critSustainedMs: 20000,
|
|
38
42
|
// network thresholds (round trip)
|
|
39
43
|
rttWarnMs: 300,
|
|
40
44
|
rttCritMs: 500,
|
|
@@ -412,10 +416,18 @@ export class QualityMonitor {
|
|
|
412
416
|
maxLoss >= this.thresholds.lossWarnRatio
|
|
413
417
|
? 1
|
|
414
418
|
: 0;
|
|
419
|
+
// Bandwidth-pressure signals (the available-outgoing-bitrate floor and
|
|
420
|
+
// the encoder bandwidth-limited ratio) are only meaningful when we're
|
|
421
|
+
// actually sending video. With the camera off/denied, Chrome's BWE has
|
|
422
|
+
// nothing to probe and availableOutgoingBitrate reads artificially low —
|
|
423
|
+
// counting that as a bad network wrongly marks audio-only participants
|
|
424
|
+
// "poor" and triggers a disable-camera suggestion for a camera that's
|
|
425
|
+
// already off. RTT + loss remain valid either way.
|
|
415
426
|
// Only count availMbps once we've seen a real reading (>0); some
|
|
416
427
|
// browsers report 0 transiently at session start.
|
|
417
|
-
const bandwidthSeverity =
|
|
418
|
-
|
|
428
|
+
const bandwidthSeverity = !this._isSendingVideo()
|
|
429
|
+
? 0
|
|
430
|
+
: availMbps > 0 && availMbps <= this.thresholds.availMbpsCrit
|
|
419
431
|
? 2
|
|
420
432
|
: bwLimitedRatio >= this.thresholds.encoderBwLimitedCritRatio
|
|
421
433
|
? 2
|
|
@@ -585,6 +597,7 @@ export class QualityMonitor {
|
|
|
585
597
|
// pressure (availMbps + encoder-bw-limited ratio), so throttled
|
|
586
598
|
// uplinks correctly drive network → critical alongside outbound.
|
|
587
599
|
const triggerCritical =
|
|
600
|
+
this._isSendingVideo() &&
|
|
588
601
|
this.outbound.level === "critical" &&
|
|
589
602
|
this.network.level === "critical";
|
|
590
603
|
const t = now();
|
|
@@ -763,6 +776,17 @@ export class QualityMonitor {
|
|
|
763
776
|
return null;
|
|
764
777
|
}
|
|
765
778
|
|
|
779
|
+
// Are we actively sending video right now (producer exists and isn't
|
|
780
|
+
// paused/closed)? Used to suppress encoder/bandwidth-pressure signals and
|
|
781
|
+
// the disable-camera suggestion when the camera is off or denied — Chrome's
|
|
782
|
+
// available-outgoing-bitrate reads artificially low with nothing to send,
|
|
783
|
+
// which would otherwise flag an audio-only participant's network as "poor"
|
|
784
|
+
// and even suggest disabling a camera that's already off.
|
|
785
|
+
_isSendingVideo() {
|
|
786
|
+
const p = this._findVideoProducer();
|
|
787
|
+
return !!(p && !p.closed && !p.paused);
|
|
788
|
+
}
|
|
789
|
+
|
|
766
790
|
_emit(evt) {
|
|
767
791
|
// Always log into the debug ring, even if onQualityEvent throws.
|
|
768
792
|
try {
|
|
@@ -39,12 +39,28 @@ export class RemoteMediaManager extends EventEmitter {
|
|
|
39
39
|
// Queue for producers that arrived before transports were ready
|
|
40
40
|
this.pendingProducers = [];
|
|
41
41
|
|
|
42
|
+
// Producer ids currently being consumed (in-flight). A producer can be
|
|
43
|
+
// announced via both the pending queue and a re-sent media.producer.created
|
|
44
|
+
// after room.join; consuming it twice yields two consumers with the same
|
|
45
|
+
// msid, which makes setRemoteDescription fail ("Duplicate a=msid lines").
|
|
46
|
+
this.consumingProducers = new Set();
|
|
47
|
+
|
|
42
48
|
// Server event listeners will be set up after socket connects
|
|
43
49
|
this.listenersSetup = false;
|
|
44
50
|
|
|
45
51
|
// Map of video element observers: participantId -> { element, observer, lastWidth, lastHeight }
|
|
46
52
|
this.videoElementTracking = new Map();
|
|
47
53
|
|
|
54
|
+
// User-chosen quality ceiling (data-saver / quality level). These cap
|
|
55
|
+
// what we'll accept from EVERY remote peer, on top of the size-based
|
|
56
|
+
// auto-selection and QualityMonitor's bad-network capping. null = no
|
|
57
|
+
// ceiling (default). Whoever wants the LOWER layer wins, so a ceiling
|
|
58
|
+
// here can never raise quality — it only ever lowers it. Set via
|
|
59
|
+
// setLayerCeiling(); both the resize auto-selector and QualityMonitor
|
|
60
|
+
// clamp through clampSpatialLayer()/clampTemporalLayer().
|
|
61
|
+
this.maxSpatialLayer = null;
|
|
62
|
+
this.maxTemporalLayer = null;
|
|
63
|
+
|
|
48
64
|
// Map of local mute states for remote streams: `${participantId}:${type}` -> boolean
|
|
49
65
|
this.localMuteStates = new Map();
|
|
50
66
|
|
|
@@ -237,10 +253,25 @@ export class RemoteMediaManager extends EventEmitter {
|
|
|
237
253
|
// Wait for device and transports to be ready before consuming
|
|
238
254
|
// Transports are created when media.transports event arrives from server
|
|
239
255
|
if (!this.mediasoup.device.loaded || !this.mediasoup.recvTransport) {
|
|
240
|
-
this.
|
|
241
|
-
|
|
256
|
+
if (!this.pendingProducers.some((p) => p.producer.id === producer.id)) {
|
|
257
|
+
this.logger.warn('Device or transport not ready yet, queuing producer:', producer.id);
|
|
258
|
+
this.pendingProducers.push({ producer, participant });
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Skip if we're already consuming this producer or already have a
|
|
264
|
+
// consumer for it. Without this, a producer announced via both the
|
|
265
|
+
// pending queue and a re-sent media.producer.created gets consumed
|
|
266
|
+
// twice → duplicate msid → setRemoteDescription parse failure.
|
|
267
|
+
if (
|
|
268
|
+
this.consumingProducers.has(producer.id) ||
|
|
269
|
+
this._isProducerConsumed(producer.id)
|
|
270
|
+
) {
|
|
271
|
+
this.logger.info('Skipping duplicate consume for producer:', producer.id);
|
|
242
272
|
return;
|
|
243
273
|
}
|
|
274
|
+
this.consumingProducers.add(producer.id);
|
|
244
275
|
|
|
245
276
|
// Emit event to let UI know we're attempting to consume a stream
|
|
246
277
|
const streamType = producer.producerType || producer.type || 'unknown';
|
|
@@ -324,7 +355,22 @@ export class RemoteMediaManager extends EventEmitter {
|
|
|
324
355
|
error: error.message,
|
|
325
356
|
canRetryManually: true
|
|
326
357
|
});
|
|
358
|
+
} finally {
|
|
359
|
+
// Clear the in-flight marker. On success the consumers map now tracks
|
|
360
|
+
// it (see _isProducerConsumed); on failure it's free to retry.
|
|
361
|
+
this.consumingProducers.delete(producer.id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Whether we already hold a consumer for this producer id.
|
|
367
|
+
* @private
|
|
368
|
+
*/
|
|
369
|
+
_isProducerConsumed(producerId) {
|
|
370
|
+
for (const data of this.consumers.values()) {
|
|
371
|
+
if (data.producerId === producerId) return true;
|
|
327
372
|
}
|
|
373
|
+
return false;
|
|
328
374
|
}
|
|
329
375
|
|
|
330
376
|
/**
|
|
@@ -488,9 +534,66 @@ export class RemoteMediaManager extends EventEmitter {
|
|
|
488
534
|
(typeof window !== 'undefined' && window.devicePixelRatio) || 1;
|
|
489
535
|
const maxPx = Math.max(width || 0, height || 0) * dpr;
|
|
490
536
|
|
|
491
|
-
|
|
492
|
-
if (maxPx <=
|
|
493
|
-
|
|
537
|
+
let layer;
|
|
538
|
+
if (maxPx <= 480) layer = 0; // ≤480 px → 270p source suffices
|
|
539
|
+
else if (maxPx <= 960) layer = 1; // ≤960 px → 540p source suffices
|
|
540
|
+
else layer = 2; // larger → need 1080p source
|
|
541
|
+
|
|
542
|
+
// Apply the user's quality ceiling (data-saver). A large tile on a
|
|
543
|
+
// metered connection should still be served the capped layer.
|
|
544
|
+
return this.clampSpatialLayer(layer);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Clamp a desired spatial layer to the user's ceiling. The lower of the
|
|
549
|
+
* two always wins, so callers (resize auto-selector, QualityMonitor) can
|
|
550
|
+
* request whatever they think is optimal and never exceed the cap.
|
|
551
|
+
* @param {number} layer - Desired spatial layer (0-2)
|
|
552
|
+
* @returns {number}
|
|
553
|
+
*/
|
|
554
|
+
clampSpatialLayer(layer) {
|
|
555
|
+
if (this.maxSpatialLayer == null) return layer;
|
|
556
|
+
return Math.min(layer, this.maxSpatialLayer);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Clamp a desired temporal layer to the user's ceiling. See clampSpatialLayer.
|
|
561
|
+
* @param {number} layer - Desired temporal layer (0-2)
|
|
562
|
+
* @returns {number}
|
|
563
|
+
*/
|
|
564
|
+
clampTemporalLayer(layer) {
|
|
565
|
+
if (this.maxTemporalLayer == null) return layer;
|
|
566
|
+
return Math.min(layer, this.maxTemporalLayer);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Set the user-chosen quality ceiling (data-saver / quality level) and
|
|
571
|
+
* immediately re-apply it to every consumer we're currently tracking, so
|
|
572
|
+
* a mid-call toggle takes effect right away. Pass null for either value to
|
|
573
|
+
* lift that ceiling.
|
|
574
|
+
* @param {number|null} maxSpatialLayer - 0-2 or null for no cap
|
|
575
|
+
* @param {number|null} maxTemporalLayer - 0-2 or null for no cap
|
|
576
|
+
*/
|
|
577
|
+
setLayerCeiling(maxSpatialLayer, maxTemporalLayer) {
|
|
578
|
+
this.maxSpatialLayer = maxSpatialLayer;
|
|
579
|
+
this.maxTemporalLayer = maxTemporalLayer;
|
|
580
|
+
this.logger.info('RemoteMediaManager :: Set Layer Ceiling ::', {
|
|
581
|
+
maxSpatialLayer,
|
|
582
|
+
maxTemporalLayer,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Re-evaluate every tracked element against the new ceiling. We
|
|
586
|
+
// recompute from the element's current size so lifting the cap also
|
|
587
|
+
// restores the size-appropriate layer, not just lowers it.
|
|
588
|
+
for (const [participantId, tracking] of this.videoElementTracking) {
|
|
589
|
+
const consumer = this._getVideoConsumerForParticipant(participantId);
|
|
590
|
+
if (!consumer) continue;
|
|
591
|
+
const optimalLayer = this._calculateOptimalSpatialLayer(
|
|
592
|
+
tracking.element.clientWidth,
|
|
593
|
+
tracking.element.clientHeight
|
|
594
|
+
);
|
|
595
|
+
this._updateConsumerLayers(consumer, optimalLayer);
|
|
596
|
+
}
|
|
494
597
|
}
|
|
495
598
|
|
|
496
599
|
/**
|
|
@@ -624,17 +727,21 @@ export class RemoteMediaManager extends EventEmitter {
|
|
|
624
727
|
}
|
|
625
728
|
|
|
626
729
|
try {
|
|
627
|
-
//
|
|
628
|
-
// temporalLayer 2
|
|
730
|
+
// spatialLayer is already clamped by callers via
|
|
731
|
+
// _calculateOptimalSpatialLayer/clampSpatialLayer. temporalLayer 2
|
|
732
|
+
// is the highest frame rate for the spatial layer; clamp it to the
|
|
733
|
+
// user's ceiling (data-saver low → ≈half frame rate).
|
|
734
|
+
const spatial = this.clampSpatialLayer(spatialLayer);
|
|
735
|
+
const temporal = this.clampTemporalLayer(2);
|
|
629
736
|
await consumer.setPreferredLayers({
|
|
630
|
-
spatialLayer:
|
|
631
|
-
temporalLayer:
|
|
737
|
+
spatialLayer: spatial,
|
|
738
|
+
temporalLayer: temporal,
|
|
632
739
|
});
|
|
633
740
|
|
|
634
741
|
this.logger.log('RemoteMediaManager :: Updated Consumer Layers ::', {
|
|
635
742
|
consumerId: consumer.id,
|
|
636
|
-
spatialLayer,
|
|
637
|
-
temporalLayer:
|
|
743
|
+
spatialLayer: spatial,
|
|
744
|
+
temporalLayer: temporal,
|
|
638
745
|
});
|
|
639
746
|
} catch (error) {
|
|
640
747
|
this.logger.error('RemoteMediaManager :: Failed to Update Consumer Layers ::', error);
|
|
@@ -355,6 +355,10 @@ export class StatsCollector {
|
|
|
355
355
|
network.local.type = localCandidate.networkType;
|
|
356
356
|
network.local.port = localCandidate.port;
|
|
357
357
|
network.local.protocol = localCandidate.protocol;
|
|
358
|
+
// host | srflx | prflx | relay — 'relay' means traffic is going
|
|
359
|
+
// through a TURN server, a strong "why is it slow" signal.
|
|
360
|
+
network.local.candidateType = localCandidate.candidateType;
|
|
361
|
+
network.local.relayProtocol = localCandidate.relayProtocol;
|
|
358
362
|
}
|
|
359
363
|
|
|
360
364
|
const remoteCandidate = stats.get(candidate.remoteCandidateId);
|
|
@@ -362,7 +366,13 @@ export class StatsCollector {
|
|
|
362
366
|
network.remote.wanIp = remoteCandidate.ip;
|
|
363
367
|
network.remote.port = remoteCandidate.port;
|
|
364
368
|
network.remote.protocol = remoteCandidate.protocol;
|
|
369
|
+
network.remote.candidateType = remoteCandidate.candidateType;
|
|
365
370
|
}
|
|
371
|
+
|
|
372
|
+
// Relay in use if either end of the nominated pair is a TURN relay.
|
|
373
|
+
network.relay =
|
|
374
|
+
localCandidate?.candidateType === 'relay' ||
|
|
375
|
+
remoteCandidate?.candidateType === 'relay';
|
|
366
376
|
}
|
|
367
377
|
}
|
|
368
378
|
|
|
@@ -395,6 +405,7 @@ export class StatsCollector {
|
|
|
395
405
|
audioStats.totalPacketSendDelay = this.validateNumber(
|
|
396
406
|
report.totalPacketSendDelay,
|
|
397
407
|
);
|
|
408
|
+
audioStats.nackCount = this.validateNumber(report.nackCount);
|
|
398
409
|
} else if (
|
|
399
410
|
report.type === 'remote-inbound-rtp' ||
|
|
400
411
|
report.type === 'inbound-rtp'
|
|
@@ -432,6 +443,12 @@ export class StatsCollector {
|
|
|
432
443
|
videoStats.qLdOther = report?.qualityLimitationDurations?.other;
|
|
433
444
|
videoStats.qlResolutionChanges =
|
|
434
445
|
report?.qualityLimitationResolutionChanges;
|
|
446
|
+
videoStats.qualityLimitationReason =
|
|
447
|
+
report?.qualityLimitationReason;
|
|
448
|
+
// Retransmits / keyframe requests — congestion + loss indicators.
|
|
449
|
+
videoStats.nackCount = this.validateNumber(report.nackCount);
|
|
450
|
+
videoStats.pliCount = this.validateNumber(report.pliCount);
|
|
451
|
+
videoStats.firCount = this.validateNumber(report.firCount);
|
|
435
452
|
} else if (report.type === 'remote-inbound-rtp') {
|
|
436
453
|
videoStats.jitter = this.validateNumber(report.jitter);
|
|
437
454
|
videoStats.roundTripTime = this.validateNumber(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unboundcx/video-sdk-client",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.8",
|
|
4
4
|
"description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"files": [
|
|
44
44
|
"index.js",
|
|
45
45
|
"VideoMeetingClient.js",
|
|
46
|
+
"videoQualityProfiles.js",
|
|
46
47
|
"VideoProcessor.js",
|
|
47
48
|
"AudioMixer.js",
|
|
48
49
|
"managers/",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Video quality tiers — single source of truth for the data-saver / quality
|
|
2
|
+
// selection feature. The UI surfaces a simple "Data saver" toggle, but the SDK
|
|
3
|
+
// is built around a richer set of named levels so we can expose finer control
|
|
4
|
+
// (an Auto/Low/Medium/High dropdown) later without touching the managers.
|
|
5
|
+
//
|
|
6
|
+
// A profile describes BOTH directions:
|
|
7
|
+
//
|
|
8
|
+
// send — how we publish our own camera:
|
|
9
|
+
// sendResolution capture/encode resolution cap (getUserMedia +
|
|
10
|
+
// simulcast top layer). null = no cap (use device
|
|
11
|
+
// default / 1080p).
|
|
12
|
+
// sendFrameRate capture frame-rate cap.
|
|
13
|
+
//
|
|
14
|
+
// receive — the ceiling on what we pull from every remote peer:
|
|
15
|
+
// maxSpatialLayer highest simulcast spatial layer we'll accept
|
|
16
|
+
// (0=≈270p, 1=≈540p, 2=≈1080p). null = no ceiling
|
|
17
|
+
// (size-based auto-selection, today's behavior).
|
|
18
|
+
// maxTemporalLayer highest temporal (frame-rate) layer we'll
|
|
19
|
+
// accept (2=full, 1=≈half, 0=≈quarter). null =
|
|
20
|
+
// no ceiling.
|
|
21
|
+
//
|
|
22
|
+
// Screen share is intentionally NOT governed by these profiles — shared text
|
|
23
|
+
// must stay legible, so the screen-share producer/consumers always run at their
|
|
24
|
+
// own high-quality single-layer profile regardless of the selected level.
|
|
25
|
+
|
|
26
|
+
/** @typedef {'auto'|'high'|'medium'|'low'} VideoQualityLevel */
|
|
27
|
+
|
|
28
|
+
export const VIDEO_QUALITY_LEVELS = ['auto', 'high', 'medium', 'low'];
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_VIDEO_QUALITY_LEVEL = 'auto';
|
|
31
|
+
|
|
32
|
+
// resolution presets reference the same keys LocalMediaManager._getVideoConstraints
|
|
33
|
+
// understands ('360p' | '540p' | '720p' | '1080p').
|
|
34
|
+
export const VIDEO_QUALITY_PROFILES = Object.freeze({
|
|
35
|
+
// Size-based auto-selection on receive; full quality on send. This is the
|
|
36
|
+
// historical default behaviour — no ceilings imposed.
|
|
37
|
+
auto: {
|
|
38
|
+
label: 'Auto',
|
|
39
|
+
sendResolution: null, // device default (≈1080p)
|
|
40
|
+
sendFrameRate: 30,
|
|
41
|
+
maxSpatialLayer: null, // no ceiling — tile-size auto-selection wins
|
|
42
|
+
maxTemporalLayer: null,
|
|
43
|
+
},
|
|
44
|
+
// Explicit "best" — same caps as auto but pins send to 1080p rather than
|
|
45
|
+
// whatever the device defaults to. Useful as the OFF target if we ever want
|
|
46
|
+
// to force-max instead of auto.
|
|
47
|
+
high: {
|
|
48
|
+
label: 'High',
|
|
49
|
+
sendResolution: '1080p',
|
|
50
|
+
sendFrameRate: 30,
|
|
51
|
+
maxSpatialLayer: 2,
|
|
52
|
+
maxTemporalLayer: 2,
|
|
53
|
+
},
|
|
54
|
+
// The "Data saver" target. Meaningful savings, still very usable face video.
|
|
55
|
+
medium: {
|
|
56
|
+
label: 'Medium',
|
|
57
|
+
sendResolution: '540p',
|
|
58
|
+
sendFrameRate: 30,
|
|
59
|
+
maxSpatialLayer: 1, // never accept the 1080p layer from anyone
|
|
60
|
+
maxTemporalLayer: 2,
|
|
61
|
+
},
|
|
62
|
+
// Aggressive savings for genuinely tight / CPU-constrained connections.
|
|
63
|
+
low: {
|
|
64
|
+
label: 'Low',
|
|
65
|
+
sendResolution: '360p',
|
|
66
|
+
sendFrameRate: 15,
|
|
67
|
+
maxSpatialLayer: 0, // lowest layer only
|
|
68
|
+
maxTemporalLayer: 1, // ≈half frame-rate on receive
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a level name to its profile, falling back to the default for unknown
|
|
74
|
+
* input so callers never have to null-check.
|
|
75
|
+
* @param {VideoQualityLevel} level
|
|
76
|
+
* @returns {typeof VIDEO_QUALITY_PROFILES.auto}
|
|
77
|
+
*/
|
|
78
|
+
export function getVideoQualityProfile(level) {
|
|
79
|
+
return (
|
|
80
|
+
VIDEO_QUALITY_PROFILES[level] ||
|
|
81
|
+
VIDEO_QUALITY_PROFILES[DEFAULT_VIDEO_QUALITY_LEVEL]
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True when the two levels would produce a different *send* configuration
|
|
87
|
+
* (capture resolution or frame-rate). Used to skip a needless camera republish
|
|
88
|
+
* when only the receive ceiling changed (e.g. auto → high).
|
|
89
|
+
* @param {VideoQualityLevel} a
|
|
90
|
+
* @param {VideoQualityLevel} b
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
export function sendConfigChanged(a, b) {
|
|
94
|
+
const pa = getVideoQualityProfile(a);
|
|
95
|
+
const pb = getVideoQualityProfile(b);
|
|
96
|
+
return (
|
|
97
|
+
pa.sendResolution !== pb.sendResolution ||
|
|
98
|
+
pa.sendFrameRate !== pb.sendFrameRate
|
|
99
|
+
);
|
|
100
|
+
}
|