@unboundcx/video-sdk-client 1.1.0

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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * VideoProcessor - Handles video processing (blur, virtual backgrounds)
3
+ * Uses MediaPipe SelfieSegmentation for background removal
4
+ */
5
+
6
+ import SelfieSegmentationPkg from '@mediapipe/selfie_segmentation';
7
+ import { Logger } from './utils/Logger.js';
8
+
9
+ // Handle CommonJS module in Vite
10
+ const { SelfieSegmentation } = SelfieSegmentationPkg;
11
+
12
+ export class VideoProcessor {
13
+ constructor(options = {}) {
14
+ this.logger = new Logger('VideoProcessor', options.debug);
15
+ this.selfieSegmentation = null;
16
+ this.isInitialized = false;
17
+ this.processing = false;
18
+ this.offScreenVideo = null;
19
+ this.offScreenCanvas = null;
20
+ this.canvasCtx = null;
21
+ this.backgroundImage = null;
22
+ this.animationFrameId = null;
23
+
24
+ // Edge refinement settings
25
+ this.edgeBlurAmount = 5; // Increased from 4px for smoother edges
26
+ this.lightWrapIntensity = 0.15; // How much background light spills onto edges
27
+ }
28
+
29
+ /**
30
+ * Initialize MediaPipe SelfieSegmentation model
31
+ */
32
+ async initialize() {
33
+ if (this.isInitialized) {
34
+ this.logger.info('VideoProcessor already initialized');
35
+ return;
36
+ }
37
+
38
+ try {
39
+ this.logger.info('Initializing MediaPipe SelfieSegmentation');
40
+
41
+ this.selfieSegmentation = new SelfieSegmentation({
42
+ locateFile: (file) => {
43
+ return `/mediapipe/selfie_segmentation/${file}`;
44
+ },
45
+ });
46
+
47
+ this.selfieSegmentation.setOptions({
48
+ modelSelection: 1, // 0 = general, 1 = landscape (better quality)
49
+ selfieMode: false, // Set to false for better non-mirrored results
50
+ });
51
+
52
+ await this.selfieSegmentation.initialize();
53
+
54
+ this.isInitialized = true;
55
+ this.logger.info('VideoProcessor initialized successfully');
56
+ } catch (error) {
57
+ this.logger.error('Failed to initialize VideoProcessor:', error);
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Apply background effect to video stream
64
+ * @param {MediaStream} videoStream - Input video stream
65
+ * @param {Object} options - Background options
66
+ * @param {string} options.type - 'blur' | 'image' | 'none'
67
+ * @param {number} options.blurLevel - Blur amount in pixels (default: 8)
68
+ * @param {string} options.imageUrl - Background image URL (for type: 'image')
69
+ * @returns {Promise<MediaStream>} Processed video stream
70
+ */
71
+ async applyBackground(videoStream, options = {}) {
72
+ const { type = 'none', blurLevel = 8, imageUrl = null } = options;
73
+
74
+ if (type === 'none') {
75
+ this.logger.info('No background effect - returning original stream');
76
+ return videoStream;
77
+ }
78
+
79
+ if (!this.isInitialized) {
80
+ await this.initialize();
81
+ }
82
+
83
+ this.logger.info('Applying background effect:', { type, blurLevel, imageUrl });
84
+
85
+ // Load background image if needed
86
+ if (type === 'image' && imageUrl) {
87
+ this.backgroundImage = await this._loadImage(imageUrl);
88
+ }
89
+
90
+ // Create off-screen video element
91
+ this.offScreenVideo = document.createElement('video');
92
+ this.offScreenVideo.srcObject = videoStream;
93
+ this.offScreenVideo.autoplay = true;
94
+ this.offScreenVideo.muted = true;
95
+ this.offScreenVideo.playsInline = true;
96
+
97
+ // Create off-screen canvas
98
+ this.offScreenCanvas = document.createElement('canvas');
99
+ this.canvasCtx = this.offScreenCanvas.getContext('2d', {
100
+ willReadFrequently: true,
101
+ });
102
+
103
+ // Wait for video metadata
104
+ await new Promise((resolve) => {
105
+ this.offScreenVideo.onloadedmetadata = () => {
106
+ this.offScreenCanvas.width = this.offScreenVideo.videoWidth;
107
+ this.offScreenCanvas.height = this.offScreenVideo.videoHeight;
108
+ this.logger.info('Video dimensions:', {
109
+ width: this.offScreenVideo.videoWidth,
110
+ height: this.offScreenVideo.videoHeight,
111
+ });
112
+ resolve();
113
+ };
114
+ });
115
+
116
+ // Wait for video to actually start playing
117
+ await this.offScreenVideo.play();
118
+
119
+ // Wait for first frame to be processed before returning stream
120
+ this.processing = true;
121
+ const firstFrameProcessed = await this._startProcessingAndWaitForFirstFrame(
122
+ type,
123
+ blurLevel,
124
+ );
125
+
126
+ if (!firstFrameProcessed) {
127
+ this.logger.warn('First frame not processed, returning anyway');
128
+ }
129
+
130
+ // Return canvas stream
131
+ const processedStream = this.offScreenCanvas.captureStream(30);
132
+ this.logger.info('Background effect applied successfully');
133
+
134
+ return processedStream;
135
+ }
136
+
137
+ /**
138
+ * Stop processing and cleanup resources
139
+ */
140
+ async cleanup() {
141
+ this.logger.info('Cleaning up VideoProcessor');
142
+
143
+ this.processing = false;
144
+
145
+ if (this.animationFrameId) {
146
+ cancelAnimationFrame(this.animationFrameId);
147
+ this.animationFrameId = null;
148
+ }
149
+
150
+ if (this.offScreenVideo) {
151
+ this.offScreenVideo.pause();
152
+ this.offScreenVideo.srcObject = null;
153
+ this.offScreenVideo = null;
154
+ }
155
+
156
+ if (this.offScreenCanvas) {
157
+ this.offScreenCanvas = null;
158
+ this.canvasCtx = null;
159
+ }
160
+
161
+ if (this._maskCanvas) {
162
+ this._maskCanvas = null;
163
+ this._maskCtx = null;
164
+ }
165
+
166
+ if (this._refinedMaskCanvas) {
167
+ this._refinedMaskCanvas = null;
168
+ this._refinedMaskCtx = null;
169
+ }
170
+
171
+ if (this._foregroundCanvas) {
172
+ this._foregroundCanvas = null;
173
+ this._foregroundCtx = null;
174
+ }
175
+
176
+ if (this._backgroundCanvas) {
177
+ this._backgroundCanvas = null;
178
+ this._backgroundCtx = null;
179
+ }
180
+
181
+ this.backgroundImage = null;
182
+
183
+ this.logger.info('VideoProcessor cleaned up');
184
+ }
185
+
186
+ /**
187
+ * Dispose of all resources including the model
188
+ */
189
+ async dispose() {
190
+ await this.cleanup();
191
+
192
+ if (this.selfieSegmentation) {
193
+ this.selfieSegmentation.close();
194
+ this.selfieSegmentation = null;
195
+ }
196
+
197
+ this.isInitialized = false;
198
+ this.logger.info('VideoProcessor disposed');
199
+ }
200
+
201
+ // Private methods
202
+
203
+ async _loadImage(url) {
204
+ return new Promise((resolve, reject) => {
205
+ const img = new Image();
206
+ img.crossOrigin = 'anonymous';
207
+ img.onload = () => resolve(img);
208
+ img.onerror = reject;
209
+ img.src = url;
210
+ });
211
+ }
212
+
213
+ async _startProcessingAndWaitForFirstFrame(type, blurLevel) {
214
+ return new Promise((resolve) => {
215
+ let firstFrameDrawn = false;
216
+
217
+ this.selfieSegmentation.onResults((results) => {
218
+ this._drawFrame(results, type, blurLevel);
219
+
220
+ if (!firstFrameDrawn) {
221
+ firstFrameDrawn = true;
222
+ this.logger.info('First frame processed and drawn');
223
+ resolve(true);
224
+ }
225
+ });
226
+
227
+ const processFrame = async () => {
228
+ if (!this.processing || !this.offScreenVideo) {
229
+ if (!firstFrameDrawn) resolve(false);
230
+ return;
231
+ }
232
+
233
+ // Check if video is ready and has dimensions
234
+ if (
235
+ this.offScreenVideo.readyState >= 2 &&
236
+ this.offScreenVideo.videoWidth > 0 &&
237
+ this.offScreenVideo.videoHeight > 0
238
+ ) {
239
+ try {
240
+ await this.selfieSegmentation.send({ image: this.offScreenVideo });
241
+ } catch (err) {
242
+ this.logger.error('Error sending frame to MediaPipe:', err);
243
+ }
244
+ }
245
+
246
+ this.animationFrameId = requestAnimationFrame(processFrame);
247
+ };
248
+
249
+ // Timeout after 5 seconds if first frame never comes
250
+ setTimeout(() => {
251
+ if (!firstFrameDrawn) {
252
+ this.logger.warn('Timeout waiting for first frame');
253
+ resolve(false);
254
+ }
255
+ }, 5000);
256
+
257
+ processFrame();
258
+ });
259
+ }
260
+
261
+ _drawFrame(results, type, blurLevel) {
262
+ if (!this.canvasCtx || !this.offScreenCanvas) return;
263
+
264
+ const { width, height} = this.offScreenCanvas;
265
+
266
+ this.canvasCtx.save();
267
+ this.canvasCtx.clearRect(0, 0, width, height);
268
+
269
+ // Create temporary canvases for processing
270
+ if (!this._maskCanvas) {
271
+ this._maskCanvas = document.createElement('canvas');
272
+ this._maskCtx = this._maskCanvas.getContext('2d');
273
+ }
274
+ if (!this._refinedMaskCanvas) {
275
+ this._refinedMaskCanvas = document.createElement('canvas');
276
+ this._refinedMaskCtx = this._refinedMaskCanvas.getContext('2d');
277
+ }
278
+ if (!this._foregroundCanvas) {
279
+ this._foregroundCanvas = document.createElement('canvas');
280
+ this._foregroundCtx = this._foregroundCanvas.getContext('2d');
281
+ }
282
+ if (!this._backgroundCanvas) {
283
+ this._backgroundCanvas = document.createElement('canvas');
284
+ this._backgroundCtx = this._backgroundCanvas.getContext('2d');
285
+ }
286
+
287
+ // Set canvas sizes
288
+ this._maskCanvas.width = width;
289
+ this._maskCanvas.height = height;
290
+ this._refinedMaskCanvas.width = width;
291
+ this._refinedMaskCanvas.height = height;
292
+ this._foregroundCanvas.width = width;
293
+ this._foregroundCanvas.height = height;
294
+ this._backgroundCanvas.width = width;
295
+ this._backgroundCanvas.height = height;
296
+
297
+ // ===== STEP 1: Multi-pass mask refinement =====
298
+ // Draw raw mask
299
+ this._maskCtx.clearRect(0, 0, width, height);
300
+ this._maskCtx.drawImage(results.segmentationMask, 0, 0, width, height);
301
+
302
+ // Apply progressive blur for smooth edges
303
+ this._refinedMaskCtx.clearRect(0, 0, width, height);
304
+ // First pass: moderate blur
305
+ this._refinedMaskCtx.filter = 'blur(2px)';
306
+ this._refinedMaskCtx.drawImage(this._maskCanvas, 0, 0);
307
+ this._refinedMaskCtx.filter = 'none';
308
+
309
+ // Second pass: additional blur on top (cumulative effect)
310
+ this._refinedMaskCtx.globalAlpha = 1.0;
311
+ this._refinedMaskCtx.filter = `blur(${this.edgeBlurAmount}px)`;
312
+ this._refinedMaskCtx.drawImage(this._maskCanvas, 0, 0);
313
+ this._refinedMaskCtx.filter = 'none';
314
+ this._refinedMaskCtx.globalAlpha = 1.0;
315
+
316
+ // ===== STEP 2: Prepare background =====
317
+ this._backgroundCtx.clearRect(0, 0, width, height);
318
+ if (type === 'image' && this.backgroundImage) {
319
+ this._backgroundCtx.drawImage(this.backgroundImage, 0, 0, width, height);
320
+ } else if (type === 'blur') {
321
+ this._backgroundCtx.filter = `blur(${blurLevel}px)`;
322
+ this._backgroundCtx.drawImage(this.offScreenVideo, 0, 0, width, height);
323
+ this._backgroundCtx.filter = 'none';
324
+ }
325
+
326
+ // ===== STEP 3: Apply light wrapping =====
327
+ // Create a blurred version of background for light wrap
328
+ const lightWrapCanvas = document.createElement('canvas');
329
+ lightWrapCanvas.width = width;
330
+ lightWrapCanvas.height = height;
331
+ const lightWrapCtx = lightWrapCanvas.getContext('2d');
332
+
333
+ // Heavy blur on background for light wrap effect
334
+ lightWrapCtx.filter = 'blur(15px)';
335
+ lightWrapCtx.drawImage(this._backgroundCanvas, 0, 0);
336
+ lightWrapCtx.filter = 'none';
337
+
338
+ // Create inverted edge mask (for light wrap on edges only)
339
+ lightWrapCtx.globalCompositeOperation = 'destination-in';
340
+ lightWrapCtx.globalAlpha = this.lightWrapIntensity;
341
+
342
+ // Draw inverted mask to only affect edges
343
+ lightWrapCtx.filter = 'blur(8px) contrast(2)';
344
+ lightWrapCtx.drawImage(this._refinedMaskCanvas, 0, 0);
345
+ lightWrapCtx.filter = 'none';
346
+ lightWrapCtx.globalAlpha = 1.0;
347
+
348
+ // ===== STEP 4: Composite foreground =====
349
+ this._foregroundCtx.clearRect(0, 0, width, height);
350
+
351
+ // Draw person
352
+ this._foregroundCtx.drawImage(this.offScreenVideo, 0, 0, width, height);
353
+
354
+ // Apply refined mask using destination-in
355
+ this._foregroundCtx.globalCompositeOperation = 'destination-in';
356
+ this._foregroundCtx.drawImage(this._refinedMaskCanvas, 0, 0);
357
+ this._foregroundCtx.globalCompositeOperation = 'source-over';
358
+
359
+ // Add light wrap on foreground edges using screen blend
360
+ this._foregroundCtx.globalCompositeOperation = 'screen';
361
+ this._foregroundCtx.globalAlpha = 0.6;
362
+ this._foregroundCtx.drawImage(lightWrapCanvas, 0, 0);
363
+ this._foregroundCtx.globalAlpha = 1.0;
364
+ this._foregroundCtx.globalCompositeOperation = 'source-over';
365
+
366
+ // ===== STEP 5: Final composite =====
367
+ // Draw background
368
+ this.canvasCtx.drawImage(this._backgroundCanvas, 0, 0);
369
+
370
+ // Draw foreground with light wrap on top
371
+ this.canvasCtx.drawImage(this._foregroundCanvas, 0, 0);
372
+
373
+ this.canvasCtx.restore();
374
+ }
375
+ }
package/index.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Video Meeting SDK - Main Entry Point
3
+ *
4
+ * @example
5
+ * import { VideoMeetingClient } from '@unboundcx/video-sdk';
6
+ *
7
+ * const client = new VideoMeetingClient({
8
+ * serverUrl: 'wss://video.example.com',
9
+ * debug: true
10
+ * });
11
+ *
12
+ * await client.connect(authToken);
13
+ * await client.joinRoom(roomId);
14
+ * await client.publishCamera();
15
+ */
16
+
17
+ // Main client
18
+ export { VideoMeetingClient } from './VideoMeetingClient.js';
19
+ export { VideoProcessor } from './VideoProcessor.js';
20
+
21
+ // Managers (for advanced usage)
22
+ export { ConnectionManager } from './managers/ConnectionManager.js';
23
+ export { MediasoupManager } from './managers/MediasoupManager.js';
24
+ export { LocalMediaManager } from './managers/LocalMediaManager.js';
25
+ export { RemoteMediaManager } from './managers/RemoteMediaManager.js';
26
+
27
+ // Utilities
28
+ export { EventEmitter } from './utils/EventEmitter.js';
29
+ export { Logger } from './utils/Logger.js';
30
+
31
+ // Error classes
32
+ export {
33
+ SDKError,
34
+ ConnectionError,
35
+ MediasoupError, // Keep for backward compatibility, but also export as MediaTransportError
36
+ MediasoupError as MediaTransportError,
37
+ PermissionDeniedError,
38
+ DeviceNotFoundError,
39
+ StateError,
40
+ RoomError,
41
+ TimeoutError,
42
+ } from './utils/errors.js';
@@ -0,0 +1,243 @@
1
+ import { io } from 'socket.io-client';
2
+ import { EventEmitter } from '../utils/EventEmitter.js';
3
+ import { Logger } from '../utils/Logger.js';
4
+ import { ConnectionError, TimeoutError } from '../utils/errors.js';
5
+
6
+ /**
7
+ * Manages Socket.io connection and signaling with the video server
8
+ *
9
+ * Events:
10
+ * - 'connected' - Connected to server
11
+ * - 'disconnected' - Disconnected from server
12
+ * - 'error' - Connection error
13
+ * - 'message' - Received message from server
14
+ */
15
+ export class ConnectionManager extends EventEmitter {
16
+ /**
17
+ * @param {Object} options
18
+ * @param {string} options.serverUrl - WebSocket server URL
19
+ * @param {boolean} options.debug - Enable debug logging
20
+ */
21
+ constructor(options) {
22
+ super();
23
+
24
+ this.serverUrl = options.serverUrl;
25
+ this.logger = new Logger('SDK:ConnectionManager', options.debug);
26
+ this.socket = null;
27
+ this.isConnected = false;
28
+ this.reconnectAttempts = 0;
29
+ this.maxReconnectAttempts = 5;
30
+ }
31
+
32
+ /**
33
+ * Connect to the server
34
+ * @param {Object} auth - Authentication data
35
+ * @returns {Promise<void>}
36
+ */
37
+ async connect(auth = {}) {
38
+ if (this.isConnected) {
39
+ this.logger.warn('Already connected');
40
+ return;
41
+ }
42
+
43
+ this.logger.info('Connecting to server:', this.serverUrl);
44
+
45
+ return new Promise((resolve, reject) => {
46
+ try {
47
+ // Match the old working videoCreateSocket.js pattern exactly
48
+ // Keep it simple: just withCredentials and auth
49
+ const socketOptions = {
50
+ withCredentials: true,
51
+ auth,
52
+ };
53
+
54
+ console.log('ConnectionManager :: Auth object being sent:', auth);
55
+ console.log('ConnectionManager :: Full socket options:', socketOptions);
56
+
57
+ this.logger.info('Creating socket.io connection with options:', {
58
+ serverUrl: this.serverUrl,
59
+ ...socketOptions
60
+ });
61
+
62
+ this.socket = io(this.serverUrl, socketOptions);
63
+
64
+ this.logger.info('Socket.io instance created, waiting for connect event');
65
+
66
+ // Setup event listeners
67
+ this.socket.on('connect', () => {
68
+ this.logger.info('Connected to server');
69
+ this.isConnected = true;
70
+ this.reconnectAttempts = 0;
71
+ this.emit('connected', { socketId: this.socket.id });
72
+ resolve();
73
+ });
74
+
75
+ this.socket.on('disconnect', (reason) => {
76
+ this.logger.warn('Disconnected from server:', reason);
77
+ this.isConnected = false;
78
+ this.emit('disconnected', { reason });
79
+ });
80
+
81
+ this.socket.on('connect_error', (error) => {
82
+ this.logger.error('Connection error:', error);
83
+ this.reconnectAttempts++;
84
+
85
+ const connectionError = new ConnectionError(
86
+ 'Failed to connect to server',
87
+ { error: error.message, attempts: this.reconnectAttempts }
88
+ );
89
+
90
+ this.emit('error', connectionError);
91
+
92
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
93
+ reject(connectionError);
94
+ }
95
+ });
96
+
97
+ // Timeout after 10 seconds
98
+ const timeout = setTimeout(() => {
99
+ if (!this.isConnected) {
100
+ this.socket?.disconnect();
101
+ reject(new TimeoutError('connect', 10000));
102
+ }
103
+ }, 10000);
104
+
105
+ this.socket.once('connect', () => clearTimeout(timeout));
106
+
107
+ } catch (error) {
108
+ this.logger.error('Failed to create socket:', error);
109
+ reject(new ConnectionError('Failed to create socket connection', { error }));
110
+ }
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Disconnect from server
116
+ * @returns {Promise<void>}
117
+ */
118
+ async disconnect() {
119
+ if (!this.socket) {
120
+ this.logger.warn('No socket to disconnect');
121
+ return;
122
+ }
123
+
124
+ this.logger.info('Disconnecting from server');
125
+
126
+ return new Promise((resolve) => {
127
+ this.socket.once('disconnect', () => {
128
+ this.isConnected = false;
129
+ this.socket = null;
130
+ resolve();
131
+ });
132
+
133
+ this.socket.disconnect();
134
+
135
+ // Force resolve after 1 second
136
+ setTimeout(resolve, 1000);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Emit an event to the server and wait for response
142
+ * @param {string} event - Event name
143
+ * @param {*} data - Event data
144
+ * @param {number} timeout - Timeout in ms (default 5000)
145
+ * @returns {Promise<*>} Server response
146
+ */
147
+ async request(event, data = {}, timeout = 5000) {
148
+ if (!this.isConnected || !this.socket) {
149
+ throw new ConnectionError('Not connected to server');
150
+ }
151
+
152
+ this.logger.log('Request:', event, data);
153
+
154
+ return new Promise((resolve, reject) => {
155
+ const timer = setTimeout(() => {
156
+ reject(new TimeoutError(event, timeout));
157
+ }, timeout);
158
+
159
+ this.socket.emit(event, data, (response) => {
160
+ clearTimeout(timer);
161
+
162
+ if (response?.error) {
163
+ this.logger.error('Request error:', event, response.error);
164
+ reject(new ConnectionError(
165
+ `Server error: ${response.error.message || 'Unknown error'}`,
166
+ { event, error: response.error }
167
+ ));
168
+ } else {
169
+ this.logger.log('Response:', event, response);
170
+ resolve(response);
171
+ }
172
+ });
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Emit an event to server without waiting for response
178
+ * @param {string} event - Event name
179
+ * @param {*} data - Event data
180
+ */
181
+ emit(event, data = {}) {
182
+ if (!this.isConnected || !this.socket) {
183
+ throw new ConnectionError('Not connected to server');
184
+ }
185
+
186
+ this.logger.log('Emit:', event, data);
187
+ this.socket.emit(event, data);
188
+ }
189
+
190
+ /**
191
+ * Alias for emit (backward compatibility)
192
+ * @param {string} event - Event name
193
+ * @param {*} data - Event data
194
+ */
195
+ send(event, data = {}) {
196
+ return this.emit(event, data);
197
+ }
198
+
199
+ /**
200
+ * Listen for server events
201
+ * @param {string} event - Event name
202
+ * @param {Function} handler - Event handler
203
+ * @returns {Function} Unsubscribe function
204
+ */
205
+ onServerEvent(event, handler) {
206
+ if (!this.socket) {
207
+ throw new ConnectionError('Socket not initialized');
208
+ }
209
+
210
+ this.logger.log('Listening for server event:', event);
211
+ this.socket.on(event, handler);
212
+
213
+ // Return unsubscribe function
214
+ return () => {
215
+ this.socket?.off(event, handler);
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Remove server event listener
221
+ * @param {string} event - Event name
222
+ * @param {Function} handler - Event handler to remove
223
+ */
224
+ offServerEvent(event, handler) {
225
+ this.socket?.off(event, handler);
226
+ }
227
+
228
+ /**
229
+ * Get connection state
230
+ * @returns {boolean}
231
+ */
232
+ get connected() {
233
+ return this.isConnected && this.socket?.connected;
234
+ }
235
+
236
+ /**
237
+ * Get socket ID
238
+ * @returns {string|null}
239
+ */
240
+ get socketId() {
241
+ return this.socket?.id || null;
242
+ }
243
+ }