@livekit/track-processors 0.4.1 → 0.5.1

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.
@@ -2,10 +2,40 @@ import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client';
2
2
  import { TrackTransformer } from './transformers';
3
3
  import { waitForTrackResolution } from './utils';
4
4
 
5
+ export interface ProcessorWrapperOptions {
6
+ /**
7
+ * Maximum frame rate for fallback canvas.captureStream implementation
8
+ * Default: 30
9
+ */
10
+ maxFps?: number;
11
+ }
12
+
5
13
  export default class ProcessorWrapper<TransformerOptions extends Record<string, unknown>>
6
14
  implements TrackProcessor<Track.Kind>
7
15
  {
16
+ /**
17
+ * Determines if the Processor is supported on the current browser
18
+ */
8
19
  static get isSupported() {
20
+ // Check for primary implementation support
21
+ const hasStreamProcessor =
22
+ typeof MediaStreamTrackGenerator !== 'undefined' &&
23
+ typeof MediaStreamTrackProcessor !== 'undefined';
24
+
25
+ // Check for fallback implementation support
26
+ const hasFallbackSupport =
27
+ typeof HTMLCanvasElement !== 'undefined' &&
28
+ typeof VideoFrame !== 'undefined' &&
29
+ 'captureStream' in HTMLCanvasElement.prototype;
30
+
31
+ // We can work if either implementation is available
32
+ return hasStreamProcessor || hasFallbackSupport;
33
+ }
34
+
35
+ /**
36
+ * Determines if modern browser APIs are supported, which yield better performance
37
+ */
38
+ static get hasModernApiSupport() {
9
39
  return (
10
40
  typeof MediaStreamTrackGenerator !== 'undefined' &&
11
41
  typeof MediaStreamTrackProcessor !== 'undefined'
@@ -22,15 +52,39 @@ export default class ProcessorWrapper<TransformerOptions extends Record<string,
22
52
 
23
53
  canvas?: OffscreenCanvas;
24
54
 
55
+ displayCanvas?: HTMLCanvasElement;
56
+
25
57
  sourceDummy?: HTMLMediaElement;
26
58
 
27
59
  processedTrack?: MediaStreamTrack;
28
60
 
29
61
  transformer: TrackTransformer<TransformerOptions>;
30
62
 
31
- constructor(transformer: TrackTransformer<TransformerOptions>, name: string) {
63
+ // For tracking whether we're using the stream API fallback
64
+ private useStreamFallback = false;
65
+
66
+ // For fallback rendering with canvas.captureStream()
67
+ private capturedStream?: MediaStream;
68
+
69
+ private animationFrameId?: number;
70
+
71
+ private renderContext?: CanvasRenderingContext2D;
72
+
73
+ private frameCallback?: (frame: VideoFrame) => void;
74
+
75
+ private processingEnabled = false;
76
+
77
+ // FPS control for fallback implementation
78
+ private maxFps: number;
79
+
80
+ constructor(
81
+ transformer: TrackTransformer<TransformerOptions>,
82
+ name: string,
83
+ options: ProcessorWrapperOptions = {},
84
+ ) {
32
85
  this.name = name;
33
86
  this.transformer = transformer;
87
+ this.maxFps = options.maxFps ?? 30;
34
88
  }
35
89
 
36
90
  private async setup(opts: ProcessorOptions<Track.Kind>) {
@@ -48,55 +102,254 @@ export default class ProcessorWrapper<TransformerOptions extends Record<string,
48
102
  this.sourceDummy.width = width ?? 300;
49
103
  }
50
104
 
51
- // TODO explore if we can do all the processing work in a webworker
52
- this.processor = new MediaStreamTrackProcessor({ track: this.source });
105
+ this.useStreamFallback = !ProcessorWrapper.hasModernApiSupport;
53
106
 
54
- this.trackGenerator = new MediaStreamTrackGenerator({
55
- kind: 'video',
56
- signalTarget: this.source,
57
- });
107
+ if (this.useStreamFallback) {
108
+ // Create a visible canvas for the fallback implementation or use an existing one if provided
109
+ const existingCanvas = document.querySelector(
110
+ 'canvas[data-livekit-processor="' + this.name + '"]',
111
+ ) as HTMLCanvasElement;
58
112
 
59
- this.canvas = new OffscreenCanvas(width ?? 300, height ?? 300);
113
+ if (existingCanvas) {
114
+ this.displayCanvas = existingCanvas;
115
+ this.displayCanvas.width = width ?? 300;
116
+ this.displayCanvas.height = height ?? 300;
117
+ } else {
118
+ this.displayCanvas = document.createElement('canvas');
119
+ this.displayCanvas.width = width ?? 300;
120
+ this.displayCanvas.height = height ?? 300;
121
+ this.displayCanvas.style.display = 'none';
122
+ this.displayCanvas.dataset.livekitProcessor = this.name;
123
+ document.body.appendChild(this.displayCanvas);
124
+ }
125
+
126
+ this.renderContext = this.displayCanvas.getContext('2d')!;
127
+ this.capturedStream = this.displayCanvas.captureStream();
128
+ this.canvas = new OffscreenCanvas(width ?? 300, height ?? 300);
129
+ } else {
130
+ // Use MediaStreamTrackProcessor API
131
+ this.processor = new MediaStreamTrackProcessor({ track: this.source });
132
+ this.trackGenerator = new MediaStreamTrackGenerator({
133
+ kind: 'video',
134
+ signalTarget: this.source,
135
+ });
136
+ this.canvas = new OffscreenCanvas(width ?? 300, height ?? 300);
137
+ }
60
138
  }
61
139
 
62
- async init(opts: ProcessorOptions<Track.Kind>) {
140
+ async init(opts: ProcessorOptions<Track.Kind>): Promise<void> {
63
141
  await this.setup(opts);
64
- if (!this.canvas || !this.processor || !this.trackGenerator) {
65
- throw new TypeError('Expected both canvas and processor to be defined after setup');
66
- }
67
142
 
68
- const readableStream = this.processor.readable;
143
+ if (!this.canvas) {
144
+ throw new TypeError('Expected canvas to be defined after setup');
145
+ }
69
146
 
70
147
  await this.transformer.init({
71
148
  outputCanvas: this.canvas,
72
149
  inputElement: this.sourceDummy as HTMLVideoElement,
73
150
  });
74
151
 
152
+ if (this.useStreamFallback) {
153
+ this.initFallbackPath();
154
+ } else {
155
+ this.initStreamProcessorPath();
156
+ }
157
+ }
158
+
159
+ private initStreamProcessorPath() {
160
+ if (!this.processor || !this.trackGenerator) {
161
+ throw new TypeError(
162
+ 'Expected processor and trackGenerator to be defined for stream processor path',
163
+ );
164
+ }
165
+
166
+ const readableStream = this.processor.readable;
75
167
  const pipedStream = readableStream.pipeThrough(this.transformer!.transformer!);
76
168
 
77
169
  pipedStream
78
170
  .pipeTo(this.trackGenerator.writable)
79
171
  .catch((e) => console.error('error when trying to pipe', e))
80
172
  .finally(() => this.destroy());
173
+
81
174
  this.processedTrack = this.trackGenerator as MediaStreamVideoTrack;
82
175
  }
83
176
 
84
- async restart(opts: ProcessorOptions<Track.Kind>) {
177
+ private initFallbackPath() {
178
+ if (!this.capturedStream || !this.source || !this.canvas || !this.renderContext) {
179
+ throw new TypeError('Missing required components for fallback implementation');
180
+ }
181
+
182
+ this.processedTrack = this.capturedStream.getVideoTracks()[0];
183
+ this.processingEnabled = true;
184
+
185
+ // Set up the frame callback for the transformer
186
+ this.frameCallback = (frame: VideoFrame) => {
187
+ if (!this.processingEnabled || !frame) {
188
+ frame.close();
189
+ return;
190
+ }
191
+
192
+ const controller = {
193
+ enqueue: (processedFrame: VideoFrame) => {
194
+ if (this.renderContext && this.displayCanvas) {
195
+ // Draw the processed frame to the visible canvas
196
+ this.renderContext.drawImage(
197
+ processedFrame,
198
+ 0,
199
+ 0,
200
+ this.displayCanvas.width,
201
+ this.displayCanvas.height,
202
+ );
203
+ processedFrame.close();
204
+ }
205
+ },
206
+ } as TransformStreamDefaultController<VideoFrame>;
207
+
208
+ try {
209
+ // Pass the frame through our transformer
210
+ // @ts-ignore - The controller expects both VideoFrame & AudioData but we're only using VideoFrame
211
+ this.transformer.transform(frame, controller);
212
+ } catch (e) {
213
+ console.error('Error in transform:', e);
214
+ frame.close();
215
+ }
216
+ };
217
+
218
+ // Start the rendering loop
219
+ this.startRenderLoop();
220
+ }
221
+
222
+ private startRenderLoop() {
223
+ if (!this.sourceDummy || !(this.sourceDummy instanceof HTMLVideoElement)) {
224
+ return;
225
+ }
226
+
227
+ // Store the last processed timestamp to avoid duplicate processing
228
+ let lastVideoTimestamp = -1;
229
+ let lastFrameTime = 0;
230
+ const videoElement = this.sourceDummy as HTMLVideoElement;
231
+ const minFrameInterval = 1000 / this.maxFps; // Minimum time between frames
232
+
233
+ // Estimate the video's native frame rate
234
+ let estimatedVideoFps = this.maxFps;
235
+ let frameTimeHistory: number[] = [];
236
+ let lastVideoTimeChange = 0;
237
+ let frameCount = 0;
238
+ let lastFpsLog = 0;
239
+
240
+ const renderLoop = () => {
241
+ if (
242
+ !this.processingEnabled ||
243
+ !this.sourceDummy ||
244
+ !(this.sourceDummy instanceof HTMLVideoElement)
245
+ ) {
246
+ return;
247
+ }
248
+
249
+ // Only process a new frame if the video has actually updated
250
+ const videoTime = videoElement.currentTime;
251
+ const now = performance.now();
252
+ const timeSinceLastFrame = now - lastFrameTime;
253
+
254
+ // Detect if video has a new frame
255
+ const hasNewFrame = videoTime !== lastVideoTimestamp;
256
+
257
+ // Update frame rate estimation if we have a new frame
258
+ if (hasNewFrame) {
259
+ if (lastVideoTimeChange > 0) {
260
+ const timeBetweenFrames = now - lastVideoTimeChange;
261
+ frameTimeHistory.push(timeBetweenFrames);
262
+
263
+ // Keep a rolling window of the last 10 frame times
264
+ if (frameTimeHistory.length > 10) {
265
+ frameTimeHistory.shift();
266
+ }
267
+
268
+ // Calculate average frame interval
269
+ if (frameTimeHistory.length > 2) {
270
+ const avgFrameTime =
271
+ frameTimeHistory.reduce((sum, time) => sum + time, 0) / frameTimeHistory.length;
272
+ estimatedVideoFps = 1000 / avgFrameTime;
273
+
274
+ // Log estimated FPS every 5 seconds in development environments
275
+ // Use a simpler check that works in browsers without process.env
276
+ const isDevelopment =
277
+ (typeof window !== 'undefined' && window.location.hostname === 'localhost') ||
278
+ window.location.hostname === '127.0.0.1';
279
+
280
+ if (isDevelopment && now - lastFpsLog > 5000) {
281
+ console.debug(
282
+ `[${this.name}] Estimated video FPS: ${estimatedVideoFps.toFixed(
283
+ 1,
284
+ )}, Processing at: ${(frameCount / 5).toFixed(1)} FPS`,
285
+ );
286
+ frameCount = 0;
287
+ lastFpsLog = now;
288
+ }
289
+ }
290
+ }
291
+ lastVideoTimeChange = now;
292
+ }
293
+
294
+ // Determine if we should process this frame
295
+ // We'll process if:
296
+ // 1. The video has a new frame
297
+ // 2. Enough time has passed since last frame (respecting maxFps)
298
+ const timeThresholdMet = timeSinceLastFrame >= minFrameInterval;
299
+
300
+ if (hasNewFrame && timeThresholdMet) {
301
+ lastVideoTimestamp = videoTime;
302
+ lastFrameTime = now;
303
+ frameCount++;
304
+
305
+ try {
306
+ // Create a VideoFrame from the video element
307
+ const frame = new VideoFrame(videoElement);
308
+
309
+ if (this.frameCallback) {
310
+ this.frameCallback(frame);
311
+ } else {
312
+ frame.close();
313
+ }
314
+ } catch (e) {
315
+ console.error('Error in render loop:', e);
316
+ }
317
+ }
318
+ this.animationFrameId = requestAnimationFrame(renderLoop);
319
+ };
320
+
321
+ this.animationFrameId = requestAnimationFrame(renderLoop);
322
+ }
323
+
324
+ async restart(opts: ProcessorOptions<Track.Kind>): Promise<void> {
85
325
  await this.destroy();
86
- return this.init(opts);
326
+ await this.init(opts);
87
327
  }
88
328
 
89
329
  async restartTransformer(...options: Parameters<(typeof this.transformer)['restart']>) {
90
330
  // @ts-ignore unclear why the restart method only accepts VideoTransformerInitOptions instead of either those or AudioTransformerInitOptions
91
- this.transformer.restart(options[0]);
331
+ await this.transformer.restart(options[0]);
92
332
  }
93
333
 
94
334
  async updateTransformerOptions(...options: Parameters<(typeof this.transformer)['update']>) {
95
- this.transformer.update(options[0]);
335
+ await this.transformer.update(options[0]);
96
336
  }
97
337
 
98
338
  async destroy() {
339
+ if (this.useStreamFallback) {
340
+ this.processingEnabled = false;
341
+ if (this.animationFrameId) {
342
+ cancelAnimationFrame(this.animationFrameId);
343
+ this.animationFrameId = undefined;
344
+ }
345
+ if (this.displayCanvas && this.displayCanvas.parentNode) {
346
+ this.displayCanvas.parentNode.removeChild(this.displayCanvas);
347
+ }
348
+ this.capturedStream?.getTracks().forEach((track) => track.stop());
349
+ } else {
350
+ await this.processor?.writableControl?.close();
351
+ this.trackGenerator?.stop();
352
+ }
99
353
  await this.transformer.destroy();
100
- this.trackGenerator?.stop();
101
354
  }
102
355
  }
package/src/index.ts CHANGED
@@ -1,26 +1,112 @@
1
- import ProcessorWrapper from './ProcessorWrapper';
1
+ import ProcessorWrapper, { ProcessorWrapperOptions } from './ProcessorWrapper';
2
2
  import BackgroundTransformer, {
3
3
  BackgroundOptions,
4
+ FrameProcessingStats,
4
5
  SegmenterOptions,
5
6
  } from './transformers/BackgroundTransformer';
6
7
 
7
8
  export * from './transformers/types';
8
9
  export { default as VideoTransformer } from './transformers/VideoTransformer';
9
- export { ProcessorWrapper, type BackgroundOptions, type SegmenterOptions, BackgroundTransformer };
10
+ export {
11
+ ProcessorWrapper,
12
+ type BackgroundOptions,
13
+ type SegmenterOptions,
14
+ BackgroundTransformer,
15
+ type ProcessorWrapperOptions,
16
+ };
17
+
18
+ /**
19
+ * Determines if the current browser supports background processors
20
+ */
21
+ export const supportsBackgroundProcessors = () =>
22
+ BackgroundTransformer.isSupported && ProcessorWrapper.isSupported;
23
+
24
+ /**
25
+ * Determines if the current browser supports modern background processors, which yield better performance
26
+ */
27
+ export const supportsModernBackgroundProcessors = () =>
28
+ BackgroundTransformer.isSupported && ProcessorWrapper.hasModernApiSupport;
10
29
 
11
- export const BackgroundBlur = (blurRadius: number = 10, segmenterOptions?: SegmenterOptions) => {
12
- return BackgroundProcessor({ blurRadius, segmenterOptions }, 'background-blur');
30
+ export interface BackgroundProcessorOptions extends ProcessorWrapperOptions {
31
+ blurRadius?: number;
32
+ imagePath?: string;
33
+ segmenterOptions?: SegmenterOptions;
34
+ assetPaths?: { tasksVisionFileSet?: string; modelAssetPath?: string };
35
+ onFrameProcessed?: (stats: FrameProcessingStats) => void;
36
+ }
37
+
38
+ export const BackgroundBlur = (
39
+ blurRadius: number = 10,
40
+ segmenterOptions?: SegmenterOptions,
41
+ onFrameProcessed?: (stats: FrameProcessingStats) => void,
42
+ processorOptions?: ProcessorWrapperOptions,
43
+ ) => {
44
+ return BackgroundProcessor(
45
+ {
46
+ blurRadius,
47
+ segmenterOptions,
48
+ onFrameProcessed,
49
+ ...processorOptions,
50
+ },
51
+ 'background-blur',
52
+ );
13
53
  };
14
54
 
15
- export const VirtualBackground = (imagePath: string, segmenterOptions?: SegmenterOptions) => {
16
- return BackgroundProcessor({ imagePath, segmenterOptions }, 'virtual-background');
55
+ export const VirtualBackground = (
56
+ imagePath: string,
57
+ segmenterOptions?: SegmenterOptions,
58
+ onFrameProcessed?: (stats: FrameProcessingStats) => void,
59
+ processorOptions?: ProcessorWrapperOptions,
60
+ ) => {
61
+ return BackgroundProcessor(
62
+ {
63
+ imagePath,
64
+ segmenterOptions,
65
+ onFrameProcessed,
66
+ ...processorOptions,
67
+ },
68
+ 'virtual-background',
69
+ );
17
70
  };
18
71
 
19
- export const BackgroundProcessor = (options: BackgroundOptions, name = 'background-processor') => {
20
- const isProcessorSupported = ProcessorWrapper.isSupported && BackgroundTransformer.isSupported;
72
+ export const BackgroundProcessor = (
73
+ options: BackgroundProcessorOptions,
74
+ name = 'background-processor',
75
+ ) => {
76
+ const isTransformerSupported = BackgroundTransformer.isSupported;
77
+ const isProcessorSupported = ProcessorWrapper.isSupported;
78
+
79
+ if (!isTransformerSupported) {
80
+ throw new Error('Background transformer is not supported in this browser');
81
+ }
82
+
21
83
  if (!isProcessorSupported) {
22
- throw new Error('processor is not supported in this browser');
84
+ throw new Error(
85
+ 'Neither MediaStreamTrackProcessor nor canvas.captureStream() fallback is supported in this browser',
86
+ );
23
87
  }
24
- const processor = new ProcessorWrapper(new BackgroundTransformer(options), name);
88
+
89
+ // Extract transformer-specific options and processor options
90
+ const {
91
+ blurRadius,
92
+ imagePath,
93
+ segmenterOptions,
94
+ assetPaths,
95
+ onFrameProcessed,
96
+ ...processorOpts
97
+ } = options;
98
+
99
+ const processor = new ProcessorWrapper(
100
+ new BackgroundTransformer({
101
+ blurRadius,
102
+ imagePath,
103
+ segmenterOptions,
104
+ assetPaths,
105
+ onFrameProcessed,
106
+ }),
107
+ name,
108
+ processorOpts,
109
+ );
110
+
25
111
  return processor;
26
112
  };