@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.
- package/LICENSE +191 -0
- package/README.md +85 -19
- package/dist/core/BlendshapeQueue.d.ts +75 -4
- package/dist/core/BlendshapeQueue.d.ts.map +1 -1
- package/dist/core/BlendshapeQueue.js +400 -24
- package/dist/core/BlendshapeQueue.js.map +1 -1
- package/dist/core/ConvaiClient.d.ts +10 -2
- package/dist/core/ConvaiClient.d.ts.map +1 -1
- package/dist/core/ConvaiClient.js +208 -57
- package/dist/core/ConvaiClient.js.map +1 -1
- package/dist/core/MessageHandler.d.ts +8 -0
- package/dist/core/MessageHandler.d.ts.map +1 -1
- package/dist/core/MessageHandler.js +63 -12
- package/dist/core/MessageHandler.js.map +1 -1
- package/dist/core/VideoManager.d.ts +40 -5
- package/dist/core/VideoManager.d.ts.map +1 -1
- package/dist/core/VideoManager.js +579 -30
- package/dist/core/VideoManager.js.map +1 -1
- package/dist/core/connectRequest.d.ts +23 -0
- package/dist/core/connectRequest.d.ts.map +1 -0
- package/dist/core/connectRequest.js +31 -0
- package/dist/core/connectRequest.js.map +1 -0
- package/dist/core/contextUpdateRequest.d.ts +4 -0
- package/dist/core/contextUpdateRequest.d.ts.map +1 -0
- package/dist/core/contextUpdateRequest.js +10 -0
- package/dist/core/contextUpdateRequest.js.map +1 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/types.d.ts +172 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/videoSourceState.d.ts +4 -0
- package/dist/core/videoSourceState.d.ts.map +1 -0
- package/dist/core/videoSourceState.js +9 -0
- package/dist/core/videoSourceState.js.map +1 -0
- package/dist/core/visionRequest.d.ts +4 -0
- package/dist/core/visionRequest.d.ts.map +1 -0
- package/dist/core/visionRequest.js +42 -0
- package/dist/core/visionRequest.js.map +1 -0
- package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
- package/dist/react/hooks/useConvaiClient.js +3 -0
- package/dist/react/hooks/useConvaiClient.js.map +1 -1
- package/dist/vanilla/types.d.ts +6 -0
- package/dist/vanilla/types.d.ts.map +1 -1
- 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
|
|
5
|
-
*
|
|
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
|
|
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
|
|
76
|
+
if (this.isWebcamPublication(track)) {
|
|
47
77
|
this._isVideoHidden = true;
|
|
48
|
-
this.
|
|
78
|
+
this.emitVideoStateChange("track_muted", { isVideoHidden: true });
|
|
49
79
|
}
|
|
50
80
|
};
|
|
51
81
|
const handleTrackUnmuted = (track) => {
|
|
52
|
-
if (track
|
|
82
|
+
if (this.isWebcamPublication(track)) {
|
|
53
83
|
this._isVideoHidden = false;
|
|
54
|
-
this.
|
|
84
|
+
this.emitVideoStateChange("track_unmuted", { isVideoHidden: false });
|
|
55
85
|
}
|
|
56
86
|
};
|
|
57
87
|
const handleTrackPublished = (publication) => {
|
|
58
|
-
if (publication
|
|
88
|
+
if (this.isWebcamPublication(publication)) {
|
|
59
89
|
this._isVideoEnabled = true;
|
|
60
|
-
this.
|
|
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
|
|
65
|
-
|
|
66
|
-
this.
|
|
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(
|
|
70
|
-
localParticipant.on(
|
|
71
|
-
localParticipant.on(
|
|
72
|
-
localParticipant.on(
|
|
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 ===
|
|
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
|
-
|
|
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(
|
|
175
|
+
throw new Error("Room not initialized");
|
|
101
176
|
}
|
|
102
177
|
try {
|
|
103
|
-
await this.
|
|
178
|
+
await this.replaceActiveVisionSource({ source: "webcam" });
|
|
179
|
+
const publication = await this.room.localParticipant.setCameraEnabled(true);
|
|
104
180
|
this._isVideoEnabled = true;
|
|
105
|
-
this.
|
|
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(
|
|
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
|
-
|
|
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(
|
|
278
|
+
throw new Error("Room not initialized");
|
|
156
279
|
}
|
|
157
280
|
try {
|
|
158
|
-
await this.
|
|
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 ===
|
|
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(
|
|
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.
|
|
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
|
}
|