@convai/web-sdk 1.5.0-beta.0 → 1.6.0-beta.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.
Files changed (44) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +85 -19
  3. package/dist/core/BlendshapeQueue.d.ts +75 -4
  4. package/dist/core/BlendshapeQueue.d.ts.map +1 -1
  5. package/dist/core/BlendshapeQueue.js +400 -24
  6. package/dist/core/BlendshapeQueue.js.map +1 -1
  7. package/dist/core/ConvaiClient.d.ts +10 -2
  8. package/dist/core/ConvaiClient.d.ts.map +1 -1
  9. package/dist/core/ConvaiClient.js +208 -57
  10. package/dist/core/ConvaiClient.js.map +1 -1
  11. package/dist/core/MessageHandler.d.ts +8 -0
  12. package/dist/core/MessageHandler.d.ts.map +1 -1
  13. package/dist/core/MessageHandler.js +63 -12
  14. package/dist/core/MessageHandler.js.map +1 -1
  15. package/dist/core/VideoManager.d.ts +40 -5
  16. package/dist/core/VideoManager.d.ts.map +1 -1
  17. package/dist/core/VideoManager.js +579 -30
  18. package/dist/core/VideoManager.js.map +1 -1
  19. package/dist/core/connectRequest.d.ts +23 -0
  20. package/dist/core/connectRequest.d.ts.map +1 -0
  21. package/dist/core/connectRequest.js +31 -0
  22. package/dist/core/connectRequest.js.map +1 -0
  23. package/dist/core/contextUpdateRequest.d.ts +4 -0
  24. package/dist/core/contextUpdateRequest.d.ts.map +1 -0
  25. package/dist/core/contextUpdateRequest.js +10 -0
  26. package/dist/core/contextUpdateRequest.js.map +1 -0
  27. package/dist/core/index.d.ts +1 -1
  28. package/dist/core/index.d.ts.map +1 -1
  29. package/dist/core/types.d.ts +172 -3
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/core/videoSourceState.d.ts +4 -0
  32. package/dist/core/videoSourceState.d.ts.map +1 -0
  33. package/dist/core/videoSourceState.js +9 -0
  34. package/dist/core/videoSourceState.js.map +1 -0
  35. package/dist/core/visionRequest.d.ts +4 -0
  36. package/dist/core/visionRequest.d.ts.map +1 -0
  37. package/dist/core/visionRequest.js +42 -0
  38. package/dist/core/visionRequest.js.map +1 -0
  39. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  40. package/dist/react/hooks/useConvaiClient.js +3 -0
  41. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  42. package/dist/vanilla/types.d.ts +6 -0
  43. package/dist/vanilla/types.d.ts.map +1 -1
  44. package/package.json +6 -3
@@ -1,9 +1,10 @@
1
+ import { Track } from "livekit-client";
1
2
  import { logger } from "../utils/logger";
2
3
  import { EventEmitter } from "./EventEmitter";
4
+ import { isWebcamVisionSource, shouldKeepExistingVisionSource, } from "./videoSourceState";
3
5
  /**
4
- * Manages video controls for LiveKit room
5
- * Provides methods to enable/disable camera, manage video devices,
6
- * and control video state.
6
+ * Manages visual source controls. LiveKit publishes real video tracks;
7
+ * WebSocket samples active sources into vision-frame messages.
7
8
  */
8
9
  export class VideoManager extends EventEmitter {
9
10
  constructor(room) {
@@ -11,6 +12,10 @@ export class VideoManager extends EventEmitter {
11
12
  this.room = null;
12
13
  this._isVideoEnabled = false;
13
14
  this._isVideoHidden = false;
15
+ this._activeVisionSource = null;
16
+ this.pendingVisionSourceTracks = new WeakMap();
17
+ this.webSocketVisionMessageSender = null;
18
+ this.webSocketVisionMode = "off";
14
19
  if (room) {
15
20
  this.setRoom(room);
16
21
  }
@@ -21,6 +26,15 @@ export class VideoManager extends EventEmitter {
21
26
  get isVideoHidden() {
22
27
  return this._isVideoHidden;
23
28
  }
29
+ get activeVisionSource() {
30
+ return this.getLiveVisionSource();
31
+ }
32
+ get hasActiveVisionSource() {
33
+ return this.activeVisionSource !== null;
34
+ }
35
+ get visionSourceState() {
36
+ return this.makeVisionSourceState();
37
+ }
24
38
  /**
25
39
  * Set the LiveKit room instance
26
40
  */
@@ -30,11 +44,27 @@ export class VideoManager extends EventEmitter {
30
44
  this.cleanupListeners();
31
45
  }
32
46
  this.room = room;
33
- if (room && room.state !== 'disconnected') {
47
+ if (room) {
34
48
  this.setupListeners();
35
49
  this.updateState();
36
50
  }
37
51
  }
52
+ setWebSocketVisionMessageSender(sender) {
53
+ this.webSocketVisionMessageSender = sender;
54
+ this.webSocketVisionMode = sender ? "enabled" : "off";
55
+ }
56
+ setWebSocketVisionDisabled() {
57
+ this.webSocketVisionMessageSender = null;
58
+ this.webSocketVisionMode = "disabled";
59
+ const activeSource = this._activeVisionSource;
60
+ activeSource?.cleanup?.();
61
+ if (activeSource?.stopTrackOnUnpublish &&
62
+ activeSource.track.readyState !== "ended") {
63
+ activeSource.track.stop();
64
+ }
65
+ this._activeVisionSource = null;
66
+ this._isVideoEnabled = false;
67
+ }
38
68
  /**
39
69
  * Setup event listeners for room
40
70
  */
@@ -43,33 +73,43 @@ export class VideoManager extends EventEmitter {
43
73
  return;
44
74
  const localParticipant = this.room.localParticipant;
45
75
  const handleTrackMuted = (track) => {
46
- if (track.source === 'camera') {
76
+ if (this.isWebcamPublication(track)) {
47
77
  this._isVideoHidden = true;
48
- this.emit('videoStateChange', { isVideoHidden: true });
78
+ this.emitVideoStateChange("track_muted", { isVideoHidden: true });
49
79
  }
50
80
  };
51
81
  const handleTrackUnmuted = (track) => {
52
- if (track.source === 'camera') {
82
+ if (this.isWebcamPublication(track)) {
53
83
  this._isVideoHidden = false;
54
- this.emit('videoStateChange', { isVideoHidden: false });
84
+ this.emitVideoStateChange("track_unmuted", { isVideoHidden: false });
55
85
  }
56
86
  };
57
87
  const handleTrackPublished = (publication) => {
58
- if (publication.source === 'camera') {
88
+ if (this.isWebcamPublication(publication)) {
59
89
  this._isVideoEnabled = true;
60
- this.emit('videoStateChange', { isVideoEnabled: true });
90
+ if (!this._activeVisionSource) {
91
+ const handle = this.createPublicationHandle(publication, "webcam", "webcam", false);
92
+ if (handle) {
93
+ this.setActiveVisionSource(handle);
94
+ }
95
+ }
96
+ this.emitVideoStateChange("track_published", { isVideoEnabled: true });
61
97
  }
62
98
  };
63
99
  const handleTrackUnpublished = (publication) => {
64
- if (publication.source === 'camera') {
65
- this._isVideoEnabled = false;
66
- this.emit('videoStateChange', { isVideoEnabled: false });
100
+ if (this.isCameraLikeSource(publication?.source)) {
101
+ const isWebcam = this.isWebcamPublication(publication);
102
+ this.clearActiveVisionSourceForPublication(publication, isWebcam ? "track_unpublished" : "vision_source_unpublished");
103
+ this.updateState();
104
+ this.emitVideoStateChange(isWebcam ? "track_unpublished" : "vision_source_unpublished", {
105
+ isVideoEnabled: this._isVideoEnabled,
106
+ });
67
107
  }
68
108
  };
69
- localParticipant.on('trackMuted', handleTrackMuted);
70
- localParticipant.on('trackUnmuted', handleTrackUnmuted);
71
- localParticipant.on('trackPublished', handleTrackPublished);
72
- localParticipant.on('trackUnpublished', handleTrackUnpublished);
109
+ localParticipant.on("trackMuted", handleTrackMuted);
110
+ localParticipant.on("trackUnmuted", handleTrackUnmuted);
111
+ localParticipant.on("trackPublished", handleTrackPublished);
112
+ localParticipant.on("trackUnpublished", handleTrackUnpublished);
73
113
  }
74
114
  /**
75
115
  * Clean up event listeners
@@ -83,26 +123,67 @@ export class VideoManager extends EventEmitter {
83
123
  * Update video state from room
84
124
  */
85
125
  updateState() {
86
- if (!this.room || this.room.state === 'disconnected') {
126
+ if (!this.room || this.room.state === "disconnected") {
87
127
  this._isVideoEnabled = false;
88
128
  this._isVideoHidden = false;
129
+ this._activeVisionSource?.cleanup?.();
130
+ this._activeVisionSource = null;
89
131
  return;
90
132
  }
133
+ let activeSource = this.getLiveVisionSource();
91
134
  const localParticipant = this.room.localParticipant;
92
- this._isVideoEnabled = localParticipant.isCameraEnabled;
135
+ if (!activeSource && localParticipant.isCameraEnabled) {
136
+ const handle = this.createPublicationHandle(localParticipant.getTrackPublication(Track.Source.Camera), "webcam", "webcam", false);
137
+ if (handle) {
138
+ this.setActiveVisionSource(handle);
139
+ activeSource = handle;
140
+ }
141
+ }
142
+ this._isVideoEnabled =
143
+ isWebcamVisionSource(activeSource?.source) ||
144
+ (!activeSource && localParticipant.isCameraEnabled);
93
145
  this._isVideoHidden = false; // LiveKit doesn't have a direct hidden state for video
94
146
  }
95
147
  /**
96
148
  * Enable video
97
149
  */
98
150
  async enableVideo() {
151
+ if (this.isWebSocketVisionTransport()) {
152
+ this.assertWebSocketVisionEnabled();
153
+ const stream = await navigator.mediaDevices.getUserMedia({
154
+ video: true,
155
+ audio: false,
156
+ });
157
+ const track = stream.getVideoTracks()[0];
158
+ if (!track) {
159
+ throw new Error("getUserMedia() produced no video track");
160
+ }
161
+ try {
162
+ await this.publishVideoTrack(track, {
163
+ name: "webcam",
164
+ source: "webcam",
165
+ stopTrackOnUnpublish: true,
166
+ });
167
+ }
168
+ catch (error) {
169
+ track.stop();
170
+ throw error;
171
+ }
172
+ return;
173
+ }
99
174
  if (!this.room) {
100
- throw new Error('Room not initialized');
175
+ throw new Error("Room not initialized");
101
176
  }
102
177
  try {
103
- await this.room.localParticipant.setCameraEnabled(true);
178
+ await this.replaceActiveVisionSource({ source: "webcam" });
179
+ const publication = await this.room.localParticipant.setCameraEnabled(true);
104
180
  this._isVideoEnabled = true;
105
- this.emit('videoStateChange', { isVideoEnabled: true });
181
+ const handle = this.createPublicationHandle(publication ||
182
+ this.room.localParticipant.getTrackPublication(Track.Source.Camera), "webcam", "webcam", false);
183
+ if (handle) {
184
+ this.setActiveVisionSource(handle);
185
+ }
186
+ this.emitVideoStateChange("webcam_enabled", { isVideoEnabled: true });
106
187
  }
107
188
  catch (error) {
108
189
  throw error;
@@ -112,13 +193,32 @@ export class VideoManager extends EventEmitter {
112
193
  * Disable video
113
194
  */
114
195
  async disableVideo() {
196
+ if (this.isWebSocketVisionTransport()) {
197
+ const activeSource = this._activeVisionSource;
198
+ if (activeSource) {
199
+ await this.unpublishVisionSource(activeSource);
200
+ }
201
+ this._isVideoEnabled = false;
202
+ this.emitVideoStateChange("webcam_disabled", { isVideoEnabled: false });
203
+ return;
204
+ }
115
205
  if (!this.room) {
116
- throw new Error('Room not initialized');
206
+ throw new Error("Room not initialized");
117
207
  }
118
208
  try {
119
209
  await this.room.localParticipant.setCameraEnabled(false);
120
210
  this._isVideoEnabled = false;
121
- this.emit('videoStateChange', { isVideoEnabled: false });
211
+ const activeSource = this._activeVisionSource;
212
+ if (activeSource) {
213
+ if (isWebcamVisionSource(activeSource.source)) {
214
+ activeSource.cleanup?.();
215
+ this._activeVisionSource = null;
216
+ }
217
+ else {
218
+ await this.unpublishVisionSource(activeSource);
219
+ }
220
+ }
221
+ this.emitVideoStateChange("webcam_disabled", { isVideoEnabled: false });
122
222
  }
123
223
  catch (error) {
124
224
  throw error;
@@ -151,11 +251,46 @@ export class VideoManager extends EventEmitter {
151
251
  * Set video device
152
252
  */
153
253
  async setVideoDevice(deviceId) {
254
+ if (this.isWebSocketVisionTransport()) {
255
+ this.assertWebSocketVisionEnabled();
256
+ const stream = await navigator.mediaDevices.getUserMedia({
257
+ video: { deviceId: { exact: deviceId } },
258
+ audio: false,
259
+ });
260
+ const track = stream.getVideoTracks()[0];
261
+ if (!track) {
262
+ throw new Error("getUserMedia() produced no video track");
263
+ }
264
+ try {
265
+ await this.publishVideoTrack(track, {
266
+ name: "webcam",
267
+ source: "webcam",
268
+ stopTrackOnUnpublish: true,
269
+ });
270
+ }
271
+ catch (error) {
272
+ track.stop();
273
+ throw error;
274
+ }
275
+ return;
276
+ }
154
277
  if (!this.room) {
155
- throw new Error('Room not initialized');
278
+ throw new Error("Room not initialized");
156
279
  }
157
280
  try {
158
- await this.room.localParticipant.setCameraEnabled(true, { deviceId });
281
+ await this.replaceActiveVisionSource({ source: "webcam" });
282
+ const publication = await this.room.localParticipant.setCameraEnabled(true, {
283
+ deviceId,
284
+ });
285
+ const handle = this.createPublicationHandle(publication ||
286
+ this.room.localParticipant.getTrackPublication(Track.Source.Camera), "webcam", "webcam", false);
287
+ if (handle) {
288
+ this.setActiveVisionSource(handle);
289
+ }
290
+ this._isVideoEnabled = true;
291
+ this.emitVideoStateChange("webcam_device_selected", {
292
+ isVideoEnabled: true,
293
+ });
159
294
  }
160
295
  catch (error) {
161
296
  logger.error("Failed to set video device:", error);
@@ -168,7 +303,7 @@ export class VideoManager extends EventEmitter {
168
303
  async getVideoDevices() {
169
304
  try {
170
305
  const devices = await navigator.mediaDevices.enumerateDevices();
171
- const videoDevices = devices.filter(device => device.kind === 'videoinput');
306
+ const videoDevices = devices.filter((device) => device.kind === "videoinput");
172
307
  return videoDevices;
173
308
  }
174
309
  catch (error) {
@@ -180,8 +315,12 @@ export class VideoManager extends EventEmitter {
180
315
  * Set video quality
181
316
  */
182
317
  async setVideoQuality(quality) {
318
+ if (this.isWebSocketVisionTransport()) {
319
+ this.assertWebSocketVisionEnabled();
320
+ return;
321
+ }
183
322
  if (!this.room) {
184
- throw new Error('Room not initialized');
323
+ throw new Error("Room not initialized");
185
324
  }
186
325
  try {
187
326
  // TODO: Implement video quality settings when LiveKit provides the API
@@ -190,15 +329,425 @@ export class VideoManager extends EventEmitter {
190
329
  throw error;
191
330
  }
192
331
  }
332
+ async publishCanvas(canvas, options = {}) {
333
+ const captureStream = canvas.captureStream;
334
+ if (typeof captureStream !== "function") {
335
+ throw new Error("Canvas captureStream() is not supported in this browser");
336
+ }
337
+ const stream = canvas.captureStream(options.fps ?? 1);
338
+ const track = stream.getVideoTracks()[0];
339
+ if (!track) {
340
+ throw new Error("Canvas captureStream() produced no video track");
341
+ }
342
+ try {
343
+ return await this.publishVideoTrack(track, {
344
+ name: options.name ?? "canvas-pov",
345
+ source: options.source ?? "canvas",
346
+ fps: options.fps,
347
+ stopTrackOnUnpublish: options.stopTrackOnUnpublish ?? true,
348
+ });
349
+ }
350
+ catch (error) {
351
+ if (track.readyState !== "ended") {
352
+ track.stop();
353
+ }
354
+ throw error;
355
+ }
356
+ }
357
+ async publishVideoTrack(track, options = {}) {
358
+ if (this.isWebSocketVisionTransport()) {
359
+ this.assertWebSocketVisionEnabled();
360
+ return this.publishWebSocketVideoTrack(track, options);
361
+ }
362
+ if (!this.room) {
363
+ throw new Error("Room not initialized");
364
+ }
365
+ const source = options.source ?? "custom";
366
+ const sourceName = options.name ?? source;
367
+ const publicationName = sourceName.toLowerCase().includes(source.toLowerCase())
368
+ ? sourceName
369
+ : `${source}:${sourceName}`;
370
+ const stopTrackOnUnpublish = options.stopTrackOnUnpublish ?? false;
371
+ await this.replaceActiveVisionSource({ source, track });
372
+ this.pendingVisionSourceTracks.set(track, source);
373
+ let publication;
374
+ try {
375
+ publication = await this.room.localParticipant.publishTrack(track, {
376
+ name: publicationName,
377
+ source: this.toLiveKitSource(source),
378
+ });
379
+ }
380
+ catch (error) {
381
+ this.pendingVisionSourceTracks.delete(track);
382
+ throw error;
383
+ }
384
+ const handle = {
385
+ track,
386
+ publication,
387
+ transport: "livekit",
388
+ sourceName,
389
+ source,
390
+ stopTrackOnUnpublish,
391
+ unpublish: async () => this.unpublishVisionSource(handle),
392
+ };
393
+ this.addTrackEndedListener(handle);
394
+ this.setActiveVisionSource(handle);
395
+ this._isVideoEnabled = isWebcamVisionSource(source);
396
+ this.emitVideoStateChange("vision_source_published", {
397
+ isVideoEnabled: this._isVideoEnabled,
398
+ });
399
+ return handle;
400
+ }
401
+ async unpublishVisionSource(source) {
402
+ const handle = this.resolveVisionSource(source);
403
+ if (!handle)
404
+ return;
405
+ if (handle.transport === "websocket") {
406
+ const wasActive = this._activeVisionSource?.track === handle.track;
407
+ handle.cleanup?.();
408
+ if (handle.stopTrackOnUnpublish && handle.track.readyState !== "ended") {
409
+ handle.track.stop();
410
+ }
411
+ if (wasActive) {
412
+ this._activeVisionSource = null;
413
+ if (isWebcamVisionSource(handle.source)) {
414
+ this._isVideoEnabled = false;
415
+ }
416
+ this.sendWebSocketVisionSourceState(false, handle.source);
417
+ this.emitVideoStateChange("vision_source_unpublished", {
418
+ isVideoEnabled: this._isVideoEnabled,
419
+ });
420
+ }
421
+ return;
422
+ }
423
+ if (!this.room) {
424
+ throw new Error("Room not initialized");
425
+ }
426
+ await this.room.localParticipant.unpublishTrack(handle.track, handle.stopTrackOnUnpublish);
427
+ if (handle.stopTrackOnUnpublish && handle.track.readyState !== "ended") {
428
+ handle.track.stop();
429
+ }
430
+ if (this._activeVisionSource?.track === handle.track) {
431
+ this._activeVisionSource.cleanup?.();
432
+ this._activeVisionSource = null;
433
+ }
434
+ this.updateState();
435
+ this.emitVideoStateChange("vision_source_unpublished", {
436
+ isVideoEnabled: this._isVideoEnabled,
437
+ });
438
+ }
439
+ async publishWebSocketVideoTrack(track, options = {}) {
440
+ const source = options.source ?? "custom";
441
+ const sourceName = options.name ?? source;
442
+ const stopTrackOnUnpublish = options.stopTrackOnUnpublish ?? false;
443
+ const fps = this.normalizeWebSocketFps(options.fps ?? 1);
444
+ const activeSource = this._activeVisionSource;
445
+ if (activeSource?.track === track) {
446
+ activeSource.cleanup?.();
447
+ this._activeVisionSource = null;
448
+ }
449
+ else {
450
+ await this.replaceActiveVisionSource({ source, track });
451
+ }
452
+ const stream = new MediaStream([track]);
453
+ const video = document.createElement("video");
454
+ video.muted = true;
455
+ video.playsInline = true;
456
+ video.srcObject = stream;
457
+ const canvas = document.createElement("canvas");
458
+ const context = canvas.getContext("2d");
459
+ if (!context) {
460
+ throw new Error("Unable to create canvas context for WebSocket vision");
461
+ }
462
+ let intervalId = null;
463
+ let stopped = false;
464
+ let captureWarningLogged = false;
465
+ let handle;
466
+ const publishFrame = () => {
467
+ if (stopped)
468
+ return;
469
+ if (track.readyState !== "live") {
470
+ this.clearEndedVisionSource(handle);
471
+ return;
472
+ }
473
+ if (!this.webSocketVisionMessageSender)
474
+ return;
475
+ const width = video.videoWidth || track.getSettings?.().width || 0;
476
+ const height = video.videoHeight || track.getSettings?.().height || 0;
477
+ if (!width || !height)
478
+ return;
479
+ try {
480
+ if (canvas.width !== width)
481
+ canvas.width = width;
482
+ if (canvas.height !== height)
483
+ canvas.height = height;
484
+ context.drawImage(video, 0, 0, width, height);
485
+ this.webSocketVisionMessageSender("vision-frame", {
486
+ image: canvas.toDataURL("image/jpeg", 0.82),
487
+ source_label: this.toBackendVisionSourceLabel(source),
488
+ capture_pts_ns: String(Math.round(performance.now() * 1000000)),
489
+ });
490
+ }
491
+ catch (error) {
492
+ if (!captureWarningLogged) {
493
+ captureWarningLogged = true;
494
+ logger.warn("Unable to capture WebSocket vision frame:", error);
495
+ }
496
+ }
497
+ };
498
+ const cleanupCapture = () => {
499
+ stopped = true;
500
+ if (intervalId !== null) {
501
+ window.clearInterval(intervalId);
502
+ intervalId = null;
503
+ }
504
+ video.pause();
505
+ video.srcObject = null;
506
+ };
507
+ handle = {
508
+ track,
509
+ transport: "websocket",
510
+ sourceName,
511
+ source,
512
+ stopTrackOnUnpublish,
513
+ unpublish: async () => this.unpublishVisionSource(handle),
514
+ cleanup: cleanupCapture,
515
+ };
516
+ this.addTrackEndedListener(handle);
517
+ this.setActiveVisionSource(handle);
518
+ this._isVideoEnabled = isWebcamVisionSource(source);
519
+ this.emitVideoStateChange("vision_source_published", {
520
+ isVideoEnabled: this._isVideoEnabled,
521
+ });
522
+ this.sendWebSocketVisionSourceState(true, source);
523
+ try {
524
+ await video.play();
525
+ }
526
+ catch (error) {
527
+ logger.warn("WebSocket vision video playback did not start immediately:", error);
528
+ }
529
+ publishFrame();
530
+ if (!stopped) {
531
+ intervalId = window.setInterval(publishFrame, Math.round(1000 / fps));
532
+ }
533
+ return handle;
534
+ }
535
+ resolveVisionSource(source) {
536
+ if (!source)
537
+ return this._activeVisionSource;
538
+ if ("track" in source)
539
+ return source;
540
+ if (this._activeVisionSource?.track === source) {
541
+ return this._activeVisionSource;
542
+ }
543
+ return {
544
+ track: source,
545
+ sourceName: source.label || "custom",
546
+ source: "custom",
547
+ transport: this.isWebSocketVisionTransport() ? "websocket" : "livekit",
548
+ stopTrackOnUnpublish: false,
549
+ unpublish: async () => this.unpublishVisionSource(source),
550
+ };
551
+ }
552
+ isWebSocketVisionTransport() {
553
+ return this.webSocketVisionMode !== "off";
554
+ }
555
+ assertWebSocketVisionEnabled() {
556
+ if (this.webSocketVisionMode !== "enabled" ||
557
+ !this.webSocketVisionMessageSender) {
558
+ throw new Error("WebSocket vision input is disabled for this session");
559
+ }
560
+ }
561
+ normalizeWebSocketFps(fps) {
562
+ if (!Number.isFinite(fps) || fps <= 0)
563
+ return 1;
564
+ return Math.max(0.2, Math.min(10, fps));
565
+ }
566
+ sendWebSocketVisionSourceState(active, source) {
567
+ if (!this.webSocketVisionMessageSender)
568
+ return;
569
+ this.webSocketVisionMessageSender("vision-source-state", {
570
+ active,
571
+ source_label: this.toBackendVisionSourceLabel(source),
572
+ });
573
+ }
574
+ toBackendVisionSourceLabel(source) {
575
+ if (source === "webcam" || source === "camera")
576
+ return "webcam";
577
+ if (source === "canvas")
578
+ return "canvas";
579
+ if (source === "screen" || source === "screen_share")
580
+ return "screen";
581
+ return "custom";
582
+ }
583
+ toLiveKitSource(source) {
584
+ if (source === "screen" || source === "screen_share") {
585
+ return Track.Source.ScreenShare;
586
+ }
587
+ if (source === "unknown")
588
+ return Track.Source.Unknown;
589
+ // P1 backend sampling listens on the video channel. Keep webcam/canvas/custom
590
+ // as camera-compatible LiveKit video tracks while preserving semantic labels
591
+ // in SDK state and track names.
592
+ return Track.Source.Camera;
593
+ }
594
+ getLiveVisionSource() {
595
+ const source = this._activeVisionSource;
596
+ if (!source)
597
+ return null;
598
+ return source.track.readyState === "live" ? source : null;
599
+ }
600
+ makeVisionSourceState(reason) {
601
+ const source = this.getLiveVisionSource();
602
+ const stateReason = reason ??
603
+ (this.webSocketVisionMode === "disabled"
604
+ ? "websocket_vision_disabled"
605
+ : undefined);
606
+ return {
607
+ active: !!source,
608
+ source: source?.source ?? null,
609
+ sourceName: source?.sourceName ?? null,
610
+ trackState: source?.track.readyState ?? null,
611
+ transport: source?.transport ??
612
+ (this.isWebSocketVisionTransport() ? "websocket" : undefined),
613
+ ...(stateReason ? { reason: stateReason } : {}),
614
+ };
615
+ }
616
+ emitVideoStateChange(reason, extra = {}) {
617
+ this.emit("videoStateChange", {
618
+ isVideoEnabled: this._isVideoEnabled,
619
+ isVideoHidden: this._isVideoHidden,
620
+ activeVisionSource: this.activeVisionSource,
621
+ hasActiveVisionSource: this.hasActiveVisionSource,
622
+ visionSourceState: this.makeVisionSourceState(reason),
623
+ ...extra,
624
+ });
625
+ }
626
+ setActiveVisionSource(handle) {
627
+ if (this._activeVisionSource?.track !== handle.track) {
628
+ this._activeVisionSource?.cleanup?.();
629
+ }
630
+ this._activeVisionSource = handle;
631
+ }
632
+ async replaceActiveVisionSource(next) {
633
+ const current = this._activeVisionSource;
634
+ if (!current)
635
+ return;
636
+ if (next.track && current.track === next.track)
637
+ return;
638
+ if (shouldKeepExistingVisionSource(current.source, next.source, next.track !== undefined)) {
639
+ return;
640
+ }
641
+ await current.unpublish();
642
+ }
643
+ createPublicationHandle(publication, source, sourceName, stopTrackOnUnpublish) {
644
+ const track = this.mediaTrackFromPublication(publication);
645
+ if (!track)
646
+ return null;
647
+ let handle;
648
+ handle = {
649
+ track,
650
+ publication,
651
+ transport: "livekit",
652
+ sourceName,
653
+ source,
654
+ stopTrackOnUnpublish,
655
+ unpublish: async () => {
656
+ if (isWebcamVisionSource(source)) {
657
+ await this.disableVideo();
658
+ return;
659
+ }
660
+ await this.unpublishVisionSource(handle);
661
+ },
662
+ };
663
+ this.addTrackEndedListener(handle);
664
+ return handle;
665
+ }
666
+ addTrackEndedListener(handle) {
667
+ const previousCleanup = handle.cleanup;
668
+ const onEnded = () => this.clearEndedVisionSource(handle);
669
+ handle.track.addEventListener?.("ended", onEnded, { once: true });
670
+ handle.cleanup = () => {
671
+ handle.track.removeEventListener?.("ended", onEnded);
672
+ previousCleanup?.();
673
+ };
674
+ }
675
+ clearEndedVisionSource(handle) {
676
+ if (this._activeVisionSource?.track !== handle.track)
677
+ return;
678
+ handle.cleanup?.();
679
+ this._activeVisionSource = null;
680
+ if (handle.transport === "websocket") {
681
+ this.sendWebSocketVisionSourceState(false, handle.source);
682
+ if (isWebcamVisionSource(handle.source)) {
683
+ this._isVideoEnabled = false;
684
+ }
685
+ this.emitVideoStateChange("track_ended");
686
+ return;
687
+ }
688
+ this.updateState();
689
+ this.emitVideoStateChange("track_ended");
690
+ }
691
+ clearActiveVisionSourceForPublication(publication, reason) {
692
+ const current = this._activeVisionSource;
693
+ if (!current)
694
+ return;
695
+ if (current.publication !== publication &&
696
+ current.track !== this.mediaTrackFromPublication(publication)) {
697
+ return;
698
+ }
699
+ current.cleanup?.();
700
+ this._activeVisionSource = null;
701
+ this.emitVideoStateChange(reason);
702
+ }
703
+ mediaTrackFromPublication(publication) {
704
+ const track = publication?.track;
705
+ const mediaTrack = track?.mediaStreamTrack ?? track;
706
+ if (mediaTrack && typeof mediaTrack.readyState === "string") {
707
+ return mediaTrack;
708
+ }
709
+ return null;
710
+ }
711
+ semanticSourceForPublication(publication) {
712
+ const track = this.mediaTrackFromPublication(publication);
713
+ if (!track)
714
+ return undefined;
715
+ if (this._activeVisionSource?.track === track) {
716
+ return this._activeVisionSource.source;
717
+ }
718
+ return this.pendingVisionSourceTracks.get(track);
719
+ }
720
+ isWebcamPublication(publication) {
721
+ const semanticSource = this.semanticSourceForPublication(publication);
722
+ if (semanticSource !== undefined) {
723
+ return isWebcamVisionSource(semanticSource);
724
+ }
725
+ return this.isCameraLikeSource(publication?.source);
726
+ }
727
+ isCameraLikeSource(source) {
728
+ return source === "camera" || source === Track.Source.Camera;
729
+ }
193
730
  /**
194
731
  * Reset state on disconnect
195
732
  */
196
733
  reset() {
197
734
  this._isVideoEnabled = false;
198
735
  this._isVideoHidden = false;
199
- this.emit('videoStateChange', {
736
+ this.webSocketVisionMessageSender = null;
737
+ this.webSocketVisionMode = "off";
738
+ const activeSource = this._activeVisionSource;
739
+ activeSource?.cleanup?.();
740
+ if (activeSource?.stopTrackOnUnpublish &&
741
+ activeSource.track.readyState !== "ended") {
742
+ activeSource.track.stop();
743
+ }
744
+ this._activeVisionSource = null;
745
+ this.emit("videoStateChange", {
200
746
  isVideoEnabled: false,
201
- isVideoHidden: false
747
+ isVideoHidden: false,
748
+ activeVisionSource: null,
749
+ hasActiveVisionSource: false,
750
+ visionSourceState: this.makeVisionSourceState("reset"),
202
751
  });
203
752
  }
204
753
  }