@unboundcx/video-sdk-client 2.0.3 → 2.0.5
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 +23 -0
- package/managers/LocalMediaManager.js +177 -6
- package/managers/MediasoupManager.js +55 -0
- package/package.json +1 -1
package/VideoMeetingClient.js
CHANGED
|
@@ -206,6 +206,14 @@ export class VideoMeetingClient extends EventEmitter {
|
|
|
206
206
|
this.emit("device:changed", data);
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
+
this.localMedia.on("camera:degraded", (data) => {
|
|
210
|
+
this.emit("camera:degraded", data);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
this.localMedia.on("camera:restarted", (data) => {
|
|
214
|
+
this.emit("camera:restarted", data);
|
|
215
|
+
});
|
|
216
|
+
|
|
209
217
|
// Remote media events
|
|
210
218
|
this.remoteMedia.on("participant:added", (data) => {
|
|
211
219
|
this.emit("participant:joined", data);
|
|
@@ -882,6 +890,21 @@ export class VideoMeetingClient extends EventEmitter {
|
|
|
882
890
|
return await this.localMedia.updateCameraBackground(options);
|
|
883
891
|
}
|
|
884
892
|
|
|
893
|
+
/**
|
|
894
|
+
* Restart the local camera in place. Use after a 'camera:degraded'
|
|
895
|
+
* event, or as a user-triggered recovery from a frozen video state.
|
|
896
|
+
* Re-acquires the camera, reapplies any active background, and
|
|
897
|
+
* replaces the track on the existing producer (no renegotiation).
|
|
898
|
+
*
|
|
899
|
+
* @param {Object} [options] - Optional deviceId/facingMode override.
|
|
900
|
+
* If omitted, reuses the last-known camera source.
|
|
901
|
+
* @returns {Promise<MediaStream>}
|
|
902
|
+
*/
|
|
903
|
+
async restartCamera(options) {
|
|
904
|
+
this._ensureInRoom();
|
|
905
|
+
return await this.localMedia.restartCamera(options);
|
|
906
|
+
}
|
|
907
|
+
|
|
885
908
|
/**
|
|
886
909
|
* Mute camera
|
|
887
910
|
* @returns {Promise<void>}
|
|
@@ -229,12 +229,11 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
229
229
|
// Store stream
|
|
230
230
|
this.streams.camera = stream;
|
|
231
231
|
|
|
232
|
-
//
|
|
233
|
-
track.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
232
|
+
// Attach health watchers (ended / muted) on the producer's
|
|
233
|
+
// current track. _watchCameraTrack handles both ended and
|
|
234
|
+
// browser-side mute (which is distinct from user mute and
|
|
235
|
+
// indicates the OS took the camera, USB unplug, etc.).
|
|
236
|
+
this._watchCameraTrack(track);
|
|
238
237
|
|
|
239
238
|
// Publish to mediasoup
|
|
240
239
|
const producer = await this.mediasoup.produce(track, {
|
|
@@ -367,9 +366,19 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
367
366
|
// Get display media
|
|
368
367
|
// Note: Audio is only available when sharing a Chrome Tab or Window (not Screen)
|
|
369
368
|
// Chrome will show an audio checkbox for Tab and Window sharing if audio is requested
|
|
369
|
+
//
|
|
370
|
+
// Video constraints: cap at 1920×1080 / 15fps so a huge external
|
|
371
|
+
// monitor or 4K display doesn't push absurd amounts of bandwidth
|
|
372
|
+
// or compute. 15fps is plenty for screen content (text + UI
|
|
373
|
+
// changes infrequently); the encoder profile in MediasoupManager
|
|
374
|
+
// uses degradationPreference="maintain-resolution" so the
|
|
375
|
+
// browser keeps the pixels sharp and drops frames if it has to.
|
|
370
376
|
const constraints = {
|
|
371
377
|
video: {
|
|
372
378
|
cursor: 'always',
|
|
379
|
+
width: { max: 1920 },
|
|
380
|
+
height: { max: 1080 },
|
|
381
|
+
frameRate: { ideal: 15, max: 30 },
|
|
373
382
|
},
|
|
374
383
|
// Request audio with processing disabled for system audio
|
|
375
384
|
// System audio should not have echo cancellation, noise suppression, or auto gain control
|
|
@@ -757,6 +766,7 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
757
766
|
|
|
758
767
|
// Replace track in producer
|
|
759
768
|
await this.producers.video.replaceTrack({ track: newTrack });
|
|
769
|
+
this._watchCameraTrack(newTrack);
|
|
760
770
|
|
|
761
771
|
// If producer was paused (camera muted), resume it
|
|
762
772
|
// When user manually changes camera, they likely want to use it
|
|
@@ -867,6 +877,7 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
867
877
|
|
|
868
878
|
const newTrack = newStream.getVideoTracks()[0];
|
|
869
879
|
await this.producers.video.replaceTrack({ track: newTrack });
|
|
880
|
+
this._watchCameraTrack(newTrack);
|
|
870
881
|
|
|
871
882
|
// Commit new state, then tear down old.
|
|
872
883
|
this.streams.camera = newStream;
|
|
@@ -929,6 +940,7 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
929
940
|
const newTrack = processedStream.getVideoTracks()[0];
|
|
930
941
|
|
|
931
942
|
await this.producers.video.replaceTrack({ track: newTrack });
|
|
943
|
+
this._watchCameraTrack(newTrack);
|
|
932
944
|
|
|
933
945
|
// Commit new state.
|
|
934
946
|
this.currentBackgroundOptions = options;
|
|
@@ -1166,6 +1178,165 @@ export class LocalMediaManager extends EventEmitter {
|
|
|
1166
1178
|
return this.muteState.microphone;
|
|
1167
1179
|
}
|
|
1168
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Attach health watchers to the producer's current video track.
|
|
1183
|
+
*
|
|
1184
|
+
* Emits:
|
|
1185
|
+
* - 'track:ended' { type: 'camera' } — kept for back-compat
|
|
1186
|
+
* - 'camera:degraded' { reason, track } — track is no longer
|
|
1187
|
+
* delivering frames (ended, OS-level muted, etc.). Remotes
|
|
1188
|
+
* will see a frozen frame until the producer is paused or the
|
|
1189
|
+
* track is replaced via restartCamera().
|
|
1190
|
+
*
|
|
1191
|
+
* The 'mute' event here is the browser's own muting (camera taken
|
|
1192
|
+
* over by another app, USB unplug, OS privacy switch) — distinct
|
|
1193
|
+
* from the user pressing our mute button.
|
|
1194
|
+
*/
|
|
1195
|
+
_watchCameraTrack(track) {
|
|
1196
|
+
if (!track) return;
|
|
1197
|
+
// Stash so subsequent watchers can replace listeners cleanly.
|
|
1198
|
+
if (this._watchedCameraTrack === track) return;
|
|
1199
|
+
this._watchedCameraTrack = track;
|
|
1200
|
+
|
|
1201
|
+
const onEnded = () => {
|
|
1202
|
+
this.logger.warn('Camera track ended');
|
|
1203
|
+
this.emit('track:ended', { type: 'camera' });
|
|
1204
|
+
if (this.streams.camera) this.streams.camera = null;
|
|
1205
|
+
this.emit('camera:degraded', { reason: 'ended', track });
|
|
1206
|
+
};
|
|
1207
|
+
const onMute = () => {
|
|
1208
|
+
this.logger.warn('Camera track muted (browser-side)');
|
|
1209
|
+
this.emit('camera:degraded', { reason: 'muted', track });
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
track.addEventListener('ended', onEnded);
|
|
1213
|
+
track.addEventListener('mute', onMute);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Restart the local camera in place.
|
|
1218
|
+
*
|
|
1219
|
+
* Re-acquires the camera via `cameraSource` (or any caller-supplied
|
|
1220
|
+
* deviceId/facingMode), reapplies the current background if one was
|
|
1221
|
+
* active, and `replaceTrack`s the live producer. Use this when
|
|
1222
|
+
* 'camera:degraded' has fired or the user manually clicks "Restart
|
|
1223
|
+
* camera" after a freeze.
|
|
1224
|
+
*
|
|
1225
|
+
* Does NOT change isCameraMuted. If the producer was paused on
|
|
1226
|
+
* degradation, the caller is responsible for resuming it once the
|
|
1227
|
+
* new track is in place (we resume here automatically for ergonomics).
|
|
1228
|
+
*
|
|
1229
|
+
* @param {Object} [options] - Optional deviceId/facingMode override.
|
|
1230
|
+
* @returns {Promise<MediaStream>} New camera stream.
|
|
1231
|
+
*/
|
|
1232
|
+
async restartCamera(options = {}) {
|
|
1233
|
+
this.logger.info('Restarting camera', options);
|
|
1234
|
+
|
|
1235
|
+
if (!this.producers.video) {
|
|
1236
|
+
throw new Error('No active camera producer to restart');
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const source =
|
|
1240
|
+
Object.keys(options).length > 0 ? options : this.cameraSource || {};
|
|
1241
|
+
|
|
1242
|
+
const oldProcessedStream = this.streams.camera;
|
|
1243
|
+
const oldRawStream = this.rawCameraStream;
|
|
1244
|
+
|
|
1245
|
+
try {
|
|
1246
|
+
const constraints = this._getVideoConstraints(source);
|
|
1247
|
+
let rawStream;
|
|
1248
|
+
try {
|
|
1249
|
+
rawStream = await navigator.mediaDevices.getUserMedia({
|
|
1250
|
+
video: constraints,
|
|
1251
|
+
});
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
if (
|
|
1254
|
+
err.name === 'OverconstrainedError' ||
|
|
1255
|
+
err.name === 'ConstraintNotSatisfiedError'
|
|
1256
|
+
) {
|
|
1257
|
+
this.logger.warn(
|
|
1258
|
+
'Restart hit constraints, retrying with ideal-only:',
|
|
1259
|
+
err.constraint,
|
|
1260
|
+
);
|
|
1261
|
+
const fallback = {
|
|
1262
|
+
...(source.deviceId ? { deviceId: { exact: source.deviceId } } : {}),
|
|
1263
|
+
...(source.facingMode
|
|
1264
|
+
? { facingMode: { exact: source.facingMode } }
|
|
1265
|
+
: {}),
|
|
1266
|
+
width: { ideal: constraints.width?.ideal },
|
|
1267
|
+
height: { ideal: constraints.height?.ideal },
|
|
1268
|
+
frameRate: constraints.frameRate,
|
|
1269
|
+
};
|
|
1270
|
+
rawStream = await navigator.mediaDevices.getUserMedia({
|
|
1271
|
+
video: fallback,
|
|
1272
|
+
});
|
|
1273
|
+
} else {
|
|
1274
|
+
throw err;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
let stream = rawStream;
|
|
1279
|
+
if (this.currentBackgroundOptions) {
|
|
1280
|
+
try {
|
|
1281
|
+
await this.videoProcessor.cleanup();
|
|
1282
|
+
} catch (cleanupErr) {
|
|
1283
|
+
this.logger.warn('Processor cleanup during restart failed:', cleanupErr);
|
|
1284
|
+
}
|
|
1285
|
+
stream = await this.videoProcessor.applyBackground(
|
|
1286
|
+
rawStream,
|
|
1287
|
+
this.currentBackgroundOptions,
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const newTrack = stream.getVideoTracks()[0];
|
|
1292
|
+
await this.producers.video.replaceTrack({ track: newTrack });
|
|
1293
|
+
|
|
1294
|
+
// If we paused the producer when degradation was detected,
|
|
1295
|
+
// resume now that a healthy track is attached. Don't touch
|
|
1296
|
+
// the user's mute state.
|
|
1297
|
+
if (this.producers.video.paused && !this.muteState.camera) {
|
|
1298
|
+
try {
|
|
1299
|
+
this.producers.video.resume();
|
|
1300
|
+
} catch (resumeErr) {
|
|
1301
|
+
this.logger.warn('Producer resume after restart failed:', resumeErr);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Commit + stop old streams.
|
|
1306
|
+
this.streams.camera = stream;
|
|
1307
|
+
this.rawCameraStream = rawStream;
|
|
1308
|
+
if (source.facingMode) {
|
|
1309
|
+
this.cameraSource = { facingMode: source.facingMode };
|
|
1310
|
+
} else {
|
|
1311
|
+
const trackDeviceId =
|
|
1312
|
+
rawStream.getVideoTracks()[0]?.getSettings().deviceId ||
|
|
1313
|
+
source.deviceId ||
|
|
1314
|
+
null;
|
|
1315
|
+
this.cameraSource = trackDeviceId ? { deviceId: trackDeviceId } : null;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (oldRawStream && oldRawStream !== rawStream) {
|
|
1319
|
+
oldRawStream.getTracks().forEach((t) => t.stop());
|
|
1320
|
+
}
|
|
1321
|
+
if (
|
|
1322
|
+
oldProcessedStream &&
|
|
1323
|
+
oldProcessedStream !== oldRawStream &&
|
|
1324
|
+
oldProcessedStream !== stream
|
|
1325
|
+
) {
|
|
1326
|
+
oldProcessedStream.getTracks().forEach((t) => t.stop());
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
this._watchCameraTrack(newTrack);
|
|
1330
|
+
this.emit('camera:restarted', { stream });
|
|
1331
|
+
this.logger.info('Camera restarted successfully');
|
|
1332
|
+
|
|
1333
|
+
return stream;
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
this.logger.error('Failed to restart camera:', error);
|
|
1336
|
+
throw wrapError(error, 'restartCamera');
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1169
1340
|
/**
|
|
1170
1341
|
* Clean up all local streams
|
|
1171
1342
|
*/
|
|
@@ -509,6 +509,60 @@ export class MediasoupManager extends EventEmitter {
|
|
|
509
509
|
const baseWidth = settings.width || 1920;
|
|
510
510
|
const baseHeight = settings.height || 1080;
|
|
511
511
|
|
|
512
|
+
const isScreenShare = options.appData?.type === "screenShare";
|
|
513
|
+
|
|
514
|
+
// ───────────────────────────────────────────────────────────
|
|
515
|
+
// Screen-share encoder profile
|
|
516
|
+
//
|
|
517
|
+
// Screen content is fundamentally different from camera:
|
|
518
|
+
// - Mostly static (text, UI, diagrams) with occasional motion
|
|
519
|
+
// - Text legibility is binary — readable or not, no in-between
|
|
520
|
+
// - Receivers either need to read it or they're zoomed out
|
|
521
|
+
//
|
|
522
|
+
// So: single high-quality layer (not 3-tier simulcast), much
|
|
523
|
+
// higher max bitrate per pixel, lower framerate target (the
|
|
524
|
+
// encoder handles this via degradationPreference + the actual
|
|
525
|
+
// capture frame rate, which Chrome lowers automatically for
|
|
526
|
+
// static content). Also set contentHint='detail' on the
|
|
527
|
+
// track itself so the encoder favours sharpness over motion.
|
|
528
|
+
// ───────────────────────────────────────────────────────────
|
|
529
|
+
if (isScreenShare) {
|
|
530
|
+
try {
|
|
531
|
+
track.contentHint = "detail";
|
|
532
|
+
} catch (err) {
|
|
533
|
+
this.logger.warn("Could not set contentHint on screenshare track:", err);
|
|
534
|
+
}
|
|
535
|
+
// Bitrate budget tuned for text crispness: ~5 Mbps at 1080p,
|
|
536
|
+
// ~3 Mbps at 720p, scaled linearly for unusual sizes. Cap
|
|
537
|
+
// upper at 8 Mbps so a huge external monitor doesn't blow
|
|
538
|
+
// through user bandwidth.
|
|
539
|
+
const pixels = baseWidth * baseHeight;
|
|
540
|
+
const bitrate = Math.min(
|
|
541
|
+
8_000_000,
|
|
542
|
+
Math.max(2_000_000, Math.round(pixels * 2.5)),
|
|
543
|
+
);
|
|
544
|
+
produceOptions.encodings = [
|
|
545
|
+
{
|
|
546
|
+
rid: "h",
|
|
547
|
+
maxBitrate: bitrate,
|
|
548
|
+
scaleResolutionDownBy: 1.0,
|
|
549
|
+
},
|
|
550
|
+
];
|
|
551
|
+
// degradationPreference="maintain-resolution" tells WebRTC
|
|
552
|
+
// to drop framerate before resolution if bandwidth tightens
|
|
553
|
+
// — exactly what we want for text.
|
|
554
|
+
produceOptions.degradationPreference = "maintain-resolution";
|
|
555
|
+
this.logger.info("VIDEO_QUALITY :: screen-share encoder profile", {
|
|
556
|
+
resolution: `${baseWidth}×${baseHeight}`,
|
|
557
|
+
bitrate: `${(bitrate / 1_000_000).toFixed(1)}Mbps`,
|
|
558
|
+
contentHint: track.contentHint,
|
|
559
|
+
degradationPreference: "maintain-resolution",
|
|
560
|
+
});
|
|
561
|
+
// Skip the camera simulcast branches below.
|
|
562
|
+
// (Falls through to the produceOptions logging.)
|
|
563
|
+
// No `return` — we still want to call transport.produce().
|
|
564
|
+
} else {
|
|
565
|
+
|
|
512
566
|
// Check if user has set a max resolution preference
|
|
513
567
|
const maxResolution = options.maxResolution || "1080p"; // Default to 1080p
|
|
514
568
|
|
|
@@ -584,6 +638,7 @@ export class MediasoupManager extends EventEmitter {
|
|
|
584
638
|
bitrate: "800kbps",
|
|
585
639
|
});
|
|
586
640
|
}
|
|
641
|
+
} // end camera-profile else (matches `if (isScreenShare)`)
|
|
587
642
|
}
|
|
588
643
|
|
|
589
644
|
this.logger.info("Calling transport.produce with options:", {
|