@unboundcx/video-sdk-client 2.0.2 → 2.0.3

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.
@@ -57,6 +57,15 @@ export class LocalMediaManager extends EventEmitter {
57
57
  // Video processor for virtual backgrounds
58
58
  this.videoProcessor = new VideoProcessor({ debug: options.debug });
59
59
  this.currentBackgroundOptions = null;
60
+
61
+ // When a virtual background is active, `streams.camera` is the
62
+ // canvas-captured (processed) stream — its track has no deviceId
63
+ // or facingMode. We keep the *raw* camera stream and the original
64
+ // source descriptor around so subsequent operations (background
65
+ // swaps, camera changes) can re-acquire or reuse the real camera
66
+ // rather than reading constraints off the canvas track.
67
+ this.rawCameraStream = null;
68
+ this.cameraSource = null; // { deviceId } | { facingMode } | null
60
69
  }
61
70
 
62
71
  /**
@@ -186,6 +195,19 @@ export class LocalMediaManager extends EventEmitter {
186
195
  }
187
196
  }
188
197
 
198
+ // Remember the raw camera stream + source descriptor BEFORE any
199
+ // background processing wraps it. Used by updateCameraBackground
200
+ // and changeCamera so they don't read constraints off a canvas
201
+ // track (which has no deviceId / facingMode).
202
+ this.rawCameraStream = stream;
203
+ if (options.facingMode) {
204
+ this.cameraSource = { facingMode: options.facingMode };
205
+ } else {
206
+ const trackDeviceId =
207
+ originalTrack.getSettings().deviceId || options.deviceId || null;
208
+ this.cameraSource = trackDeviceId ? { deviceId: trackDeviceId } : null;
209
+ }
210
+
189
211
  // Apply background effect if specified
190
212
  if (options.background && options.background.type !== 'none') {
191
213
  this.logger.info('Applying background effect:', options.background);
@@ -442,6 +464,22 @@ export class LocalMediaManager extends EventEmitter {
442
464
  // Stop all tracks
443
465
  this.streams.camera.getTracks().forEach((track) => track.stop());
444
466
 
467
+ // Also stop the raw camera stream if it's distinct (background was
468
+ // active — processed/canvas stream wraps the raw camera stream).
469
+ if (this.rawCameraStream && this.rawCameraStream !== this.streams.camera) {
470
+ this.rawCameraStream.getTracks().forEach((t) => t.stop());
471
+ }
472
+
473
+ // Tear down processor if a background was active.
474
+ if (this.currentBackgroundOptions) {
475
+ try {
476
+ await this.videoProcessor.cleanup();
477
+ } catch (cleanupErr) {
478
+ this.logger.warn('Processor cleanup during stopCamera failed:', cleanupErr);
479
+ }
480
+ this.currentBackgroundOptions = null;
481
+ }
482
+
445
483
  // Close producer
446
484
  if (this.producers.video) {
447
485
  await this.mediasoup.closeProducer('video');
@@ -449,6 +487,8 @@ export class LocalMediaManager extends EventEmitter {
449
487
  }
450
488
 
451
489
  this.streams.camera = null;
490
+ this.rawCameraStream = null;
491
+ this.cameraSource = null;
452
492
 
453
493
  this.emit('stream:removed', { type: 'camera' });
454
494
  }
@@ -687,18 +727,28 @@ export class LocalMediaManager extends EventEmitter {
687
727
  throw new Error('No active camera to change');
688
728
  }
689
729
 
730
+ const oldProcessedStream = this.streams.camera;
731
+ const oldRawStream = this.rawCameraStream;
732
+
690
733
  try {
691
- // Get new stream
734
+ // Get new stream (always fresh — camera device is changing).
692
735
  const constraints = this._getVideoConstraints(options);
693
- let stream = await navigator.mediaDevices.getUserMedia({
736
+ const rawStream = await navigator.mediaDevices.getUserMedia({
694
737
  video: constraints,
695
738
  });
696
739
 
740
+ let stream = rawStream;
741
+
697
742
  // Reapply background effect if it was active
698
743
  if (this.currentBackgroundOptions) {
699
744
  this.logger.info('Reapplying background effect after camera change');
745
+ try {
746
+ await this.videoProcessor.cleanup();
747
+ } catch (cleanupErr) {
748
+ this.logger.warn('Processor cleanup during camera change failed:', cleanupErr);
749
+ }
700
750
  stream = await this.videoProcessor.applyBackground(
701
- stream,
751
+ rawStream,
702
752
  this.currentBackgroundOptions,
703
753
  );
704
754
  }
@@ -716,13 +766,32 @@ export class LocalMediaManager extends EventEmitter {
716
766
  this.muteState.camera = false;
717
767
  }
718
768
 
719
- // Stop old stream
720
- if (this.streams.camera) {
721
- this.streams.camera.getTracks().forEach((track) => track.stop());
769
+ // Stop the OLD raw camera stream (the actual device). The old
770
+ // processed/canvas stream's track is no longer attached to the
771
+ // producer; stop it too so the canvas doesn't keep pumping.
772
+ if (oldRawStream && oldRawStream !== rawStream) {
773
+ oldRawStream.getTracks().forEach((t) => t.stop());
774
+ }
775
+ if (
776
+ oldProcessedStream &&
777
+ oldProcessedStream !== oldRawStream &&
778
+ oldProcessedStream !== stream
779
+ ) {
780
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
722
781
  }
723
782
 
724
- // Store new stream
783
+ // Store new streams + source descriptor.
725
784
  this.streams.camera = stream;
785
+ this.rawCameraStream = rawStream;
786
+ if (options.facingMode) {
787
+ this.cameraSource = { facingMode: options.facingMode };
788
+ } else {
789
+ const trackDeviceId =
790
+ rawStream.getVideoTracks()[0]?.getSettings().deviceId ||
791
+ options.deviceId ||
792
+ null;
793
+ this.cameraSource = trackDeviceId ? { deviceId: trackDeviceId } : null;
794
+ }
726
795
 
727
796
  this.logger.info('Camera changed successfully');
728
797
 
@@ -766,66 +835,127 @@ export class LocalMediaManager extends EventEmitter {
766
835
  throw new Error('No active video producer');
767
836
  }
768
837
 
769
- try {
770
- // Clean up old processor if exists
771
- if (this.currentBackgroundOptions) {
772
- await this.videoProcessor.cleanup();
773
- }
774
-
775
- // Get current device ID
776
- const currentTrack = this.streams.camera.getVideoTracks()[0];
777
- const deviceId = currentTrack.getSettings().deviceId;
838
+ // Remember everything we'd need to restore on failure. We do NOT
839
+ // stop the existing canvas / camera or tear down the processor
840
+ // until the NEW track has been swapped in successfully — otherwise
841
+ // a failure mid-swap leaves the producer holding a stopped track
842
+ // and the user's video freezes locally and on remotes.
843
+ const hadBackground = !!this.currentBackgroundOptions;
844
+ const oldProcessedStream = this.streams.camera;
845
+ const oldRawStream = this.rawCameraStream;
778
846
 
779
- // If removing background, restart with original stream
847
+ try {
848
+ // REMOVE BACKGROUND
780
849
  if (options.type === 'none') {
781
850
  this.logger.info('Removing background effect');
782
- this.currentBackgroundOptions = null;
783
851
 
784
- const constraints = this._getVideoConstraints({ deviceId });
785
- const newStream = await navigator.mediaDevices.getUserMedia({
786
- video: constraints,
787
- });
852
+ // Prefer the raw camera stream we already have — its track is
853
+ // the real device track. If it's somehow gone, fall back to
854
+ // getUserMedia using the recorded source.
855
+ let newStream = null;
856
+ const rawTrack = oldRawStream?.getVideoTracks?.()[0];
857
+ if (rawTrack && rawTrack.readyState === 'live') {
858
+ newStream = oldRawStream;
859
+ } else {
860
+ const constraints = this._getVideoConstraints(
861
+ this.cameraSource || {},
862
+ );
863
+ newStream = await navigator.mediaDevices.getUserMedia({
864
+ video: constraints,
865
+ });
866
+ }
788
867
 
789
868
  const newTrack = newStream.getVideoTracks()[0];
790
-
791
869
  await this.producers.video.replaceTrack({ track: newTrack });
792
870
 
793
- this.streams.camera.getTracks().forEach((t) => t.stop());
871
+ // Commit new state, then tear down old.
794
872
  this.streams.camera = newStream;
873
+ this.rawCameraStream = newStream;
874
+ this.currentBackgroundOptions = null;
875
+
876
+ if (hadBackground) {
877
+ try {
878
+ await this.videoProcessor.cleanup();
879
+ } catch (cleanupErr) {
880
+ this.logger.warn('Processor cleanup after remove failed:', cleanupErr);
881
+ }
882
+ // Stop the processed (canvas) stream's track if it's
883
+ // distinct from the raw stream we just promoted.
884
+ if (oldProcessedStream && oldProcessedStream !== newStream) {
885
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
886
+ }
887
+ }
795
888
 
796
889
  this.logger.info('Background effect removed');
797
890
  this.emit('background:changed', { type: 'none' });
798
-
799
891
  return newStream;
800
892
  }
801
893
 
802
- // Apply new background effect
803
- this.currentBackgroundOptions = options;
804
-
805
- // Get fresh stream
806
- const constraints = this._getVideoConstraints({ deviceId });
807
- let stream = await navigator.mediaDevices.getUserMedia({
808
- video: constraints,
809
- });
894
+ // APPLY / SWAP BACKGROUND
895
+ //
896
+ // Reuse the raw camera stream if it's still live — that's the
897
+ // common case for switching between backgrounds without doing
898
+ // a second getUserMedia (which is slow and can hit
899
+ // OverconstrainedError because the canvas track has no
900
+ // deviceId).
901
+ let rawStream = oldRawStream;
902
+ const rawTrack = rawStream?.getVideoTracks?.()[0];
903
+ const rawAlive = !!rawTrack && rawTrack.readyState === 'live';
904
+
905
+ if (!rawAlive) {
906
+ const constraints = this._getVideoConstraints(
907
+ this.cameraSource || {},
908
+ );
909
+ rawStream = await navigator.mediaDevices.getUserMedia({
910
+ video: constraints,
911
+ });
912
+ }
810
913
 
811
- // Apply background
812
- stream = await this.videoProcessor.applyBackground(stream, options);
914
+ // If swapping (background already active) we must tear down the
915
+ // processor before starting a new one — its canvas / offscreen
916
+ // video are single-instance state.
917
+ if (hadBackground) {
918
+ try {
919
+ await this.videoProcessor.cleanup();
920
+ } catch (cleanupErr) {
921
+ this.logger.warn('Processor cleanup before swap failed:', cleanupErr);
922
+ }
923
+ }
813
924
 
814
- const newTrack = stream.getVideoTracks()[0];
925
+ const processedStream = await this.videoProcessor.applyBackground(
926
+ rawStream,
927
+ options,
928
+ );
929
+ const newTrack = processedStream.getVideoTracks()[0];
815
930
 
816
- // Replace track in producer
817
931
  await this.producers.video.replaceTrack({ track: newTrack });
818
932
 
819
- // Stop old stream
820
- this.streams.camera.getTracks().forEach((t) => t.stop());
821
- this.streams.camera = stream;
933
+ // Commit new state.
934
+ this.currentBackgroundOptions = options;
935
+ this.streams.camera = processedStream;
936
+ this.rawCameraStream = rawStream;
937
+
938
+ // Stop the OLD processed canvas stream's track (it was wrapping
939
+ // the processor we just cleaned up). The raw stream is
940
+ // intentionally preserved — it's still feeding the new processor.
941
+ if (
942
+ hadBackground &&
943
+ oldProcessedStream &&
944
+ oldProcessedStream !== processedStream &&
945
+ oldProcessedStream !== rawStream
946
+ ) {
947
+ oldProcessedStream.getTracks().forEach((t) => t.stop());
948
+ }
822
949
 
823
950
  this.logger.info('Background effect updated successfully');
824
951
  this.emit('background:changed', options);
825
952
 
826
- return stream;
953
+ return processedStream;
827
954
  } catch (error) {
828
955
  this.logger.error('Failed to update camera background:', error);
956
+ // We did not mutate streams.camera or stop the old track until
957
+ // after the swap succeeded, so the producer is still holding a
958
+ // live track and the user's video keeps flowing.
829
959
  throw wrapError(error, 'updateCameraBackground');
830
960
  }
831
961
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/video-sdk-client",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
5
5
  "type": "module",
6
6
  "main": "index.js",