@livekit/track-processors 0.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.
Files changed (48) hide show
  1. package/LICENSE +175 -0
  2. package/README.md +39 -0
  3. package/dist/BackgroundProcessor.d.ts +23 -0
  4. package/dist/BackgroundProcessor.js +114 -0
  5. package/dist/BackgroundProcessor.js.map +1 -0
  6. package/dist/BaseVideoProcessor.d.ts +28 -0
  7. package/dist/BaseVideoProcessor.js +110 -0
  8. package/dist/BaseVideoProcessor.js.map +1 -0
  9. package/dist/ProcessorPipeline.d.ts +18 -0
  10. package/dist/ProcessorPipeline.js +39 -0
  11. package/dist/ProcessorPipeline.js.map +1 -0
  12. package/dist/index.d.ts +4 -0
  13. package/dist/index.js +28 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/transformers/BackgroundTransformer.d.ts +23 -0
  16. package/dist/transformers/BackgroundTransformer.js +139 -0
  17. package/dist/transformers/BackgroundTransformer.js.map +1 -0
  18. package/dist/transformers/DummyTransformer.d.ts +6 -0
  19. package/dist/transformers/DummyTransformer.js +10 -0
  20. package/dist/transformers/DummyTransformer.js.map +1 -0
  21. package/dist/transformers/MediaPipeHolisticTrackerTransformer.d.ts +18 -0
  22. package/dist/transformers/MediaPipeHolisticTrackerTransformer.js +44 -0
  23. package/dist/transformers/MediaPipeHolisticTrackerTransformer.js.map +1 -0
  24. package/dist/transformers/Untitled-1.d.ts +1 -0
  25. package/dist/transformers/Untitled-1.js +219 -0
  26. package/dist/transformers/Untitled-1.js.map +1 -0
  27. package/dist/transformers/VideoTransformer.d.ts +12 -0
  28. package/dist/transformers/VideoTransformer.js +23 -0
  29. package/dist/transformers/VideoTransformer.js.map +1 -0
  30. package/dist/transformers/index.d.ts +2 -0
  31. package/dist/transformers/index.js +3 -0
  32. package/dist/transformers/index.js.map +1 -0
  33. package/dist/transformers/types.d.ts +11 -0
  34. package/dist/transformers/types.js +2 -0
  35. package/dist/transformers/types.js.map +1 -0
  36. package/dist/utils.d.ts +2 -0
  37. package/dist/utils.js +3 -0
  38. package/dist/utils.js.map +1 -0
  39. package/package.json +56 -0
  40. package/src/ProcessorPipeline.ts +69 -0
  41. package/src/index.ts +36 -0
  42. package/src/transformers/BackgroundTransformer.ts +195 -0
  43. package/src/transformers/DummyTransformer.ts +11 -0
  44. package/src/transformers/MediaPipeHolisticTrackerTransformer.ts +60 -0
  45. package/src/transformers/VideoTransformer.ts +39 -0
  46. package/src/transformers/index.ts +2 -0
  47. package/src/transformers/types.ts +17 -0
  48. package/src/utils.ts +2 -0
@@ -0,0 +1,69 @@
1
+ import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client';
2
+ import { VideoTrackTransformer } from './transformers';
3
+
4
+ export default class ProcessorPipeline implements TrackProcessor<Track.Kind> {
5
+ static get isSupported() {
6
+ return (
7
+ typeof MediaStreamTrackGenerator !== 'undefined' &&
8
+ typeof MediaStreamTrackProcessor !== 'undefined'
9
+ );
10
+ }
11
+
12
+ name: string;
13
+
14
+ source?: MediaStreamVideoTrack;
15
+
16
+ sourceSettings?: MediaTrackSettings;
17
+
18
+ processor?: MediaStreamTrackProcessor<VideoFrame>;
19
+
20
+ trackGenerator?: MediaStreamTrackGenerator<VideoFrame>;
21
+
22
+ canvas?: OffscreenCanvas;
23
+
24
+ sourceDummy?: HTMLMediaElement;
25
+
26
+ processedTrack?: MediaStreamTrack;
27
+
28
+ transformers: Array<VideoTrackTransformer>;
29
+
30
+ constructor(transformers: Array<VideoTrackTransformer>, name: string) {
31
+ this.name = name;
32
+ this.transformers = transformers;
33
+ }
34
+
35
+ async init(opts: ProcessorOptions<Track.Kind>) {
36
+ this.source = opts.track as MediaStreamVideoTrack;
37
+ this.sourceSettings = this.source.getSettings();
38
+ this.sourceDummy = opts.element;
39
+ if (!(this.sourceDummy instanceof HTMLVideoElement)) {
40
+ throw TypeError('Currently only video transformers are supported');
41
+ }
42
+ // TODO explore if we can do all the processing work in a webworker
43
+ this.processor = new MediaStreamTrackProcessor({ track: this.source });
44
+ this.trackGenerator = new MediaStreamTrackGenerator({ kind: 'video' });
45
+
46
+ this.canvas = new OffscreenCanvas(
47
+ this.sourceSettings.width ?? 300,
48
+ this.sourceSettings.height ?? 300,
49
+ );
50
+
51
+ let readableStream = this.processor.readable;
52
+ for (const transformer of this.transformers) {
53
+ transformer.init({
54
+ outputCanvas: this.canvas,
55
+ inputElement: this.sourceDummy!,
56
+ });
57
+ readableStream = readableStream.pipeThrough(transformer!.transformer!);
58
+ }
59
+ readableStream.pipeTo(this.trackGenerator.writable);
60
+ this.processedTrack = this.trackGenerator as MediaStreamVideoTrack;
61
+ }
62
+
63
+ async destroy() {
64
+ for (const transformer of this.transformers) {
65
+ await transformer.destroy();
66
+ }
67
+ this.trackGenerator?.stop();
68
+ }
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,36 @@
1
+ import ProcessorPipeline from './ProcessorPipeline';
2
+ import BackgroundTransformer from './transformers/BackgroundTransformer';
3
+ import DummyTransformer from './transformers/DummyTransformer';
4
+
5
+ export const BackgroundBlur = (blurRadius: number = 10) => {
6
+ const isPipelineSupported = ProcessorPipeline.isSupported && BackgroundTransformer.isSupported;
7
+ if (!isPipelineSupported) {
8
+ throw new Error('pipeline is not supported in this browser');
9
+ }
10
+ const pipeline = new ProcessorPipeline(
11
+ [new BackgroundTransformer({ blurRadius })],
12
+ 'background-blur',
13
+ );
14
+ return pipeline;
15
+ };
16
+
17
+ export const VirtualBackground = (imagePath: string) => {
18
+ const isPipelineSupported = ProcessorPipeline.isSupported && BackgroundTransformer.isSupported;
19
+ if (!isPipelineSupported) {
20
+ throw new Error('pipeline is not supported in this browser');
21
+ }
22
+ const pipeline = new ProcessorPipeline(
23
+ [new BackgroundTransformer({ imagePath })],
24
+ 'virtual-background',
25
+ );
26
+ return pipeline;
27
+ };
28
+
29
+ export const Dummy = () => {
30
+ const isPipelineSupported = ProcessorPipeline.isSupported && BackgroundTransformer.isSupported;
31
+ if (!isPipelineSupported) {
32
+ throw new Error('pipeline is not supported in this browser');
33
+ }
34
+ const pipeline = new ProcessorPipeline([new DummyTransformer()], 'dummy');
35
+ return pipeline;
36
+ };
@@ -0,0 +1,195 @@
1
+ import * as vision from '@mediapipe/tasks-vision';
2
+ import VideoTransformer from './VideoTransformer';
3
+ import { VideoTransformerInitOptions } from './types';
4
+
5
+ export type BackgroundOptions = {
6
+ blurRadius?: number;
7
+ imagePath?: string;
8
+ };
9
+
10
+ export default class BackgroundProcessor extends VideoTransformer {
11
+ static get isSupported() {
12
+ return typeof OffscreenCanvas !== 'undefined';
13
+ }
14
+
15
+ imageSegmenter?: vision.ImageSegmenter;
16
+
17
+ segmentationResults: vision.ImageSegmenterResult | undefined;
18
+
19
+ backgroundImage: ImageBitmap | null = null;
20
+
21
+ blurRadius?: number;
22
+
23
+ constructor(opts: BackgroundOptions) {
24
+ super();
25
+ if (opts.blurRadius) {
26
+ this.blurRadius = opts.blurRadius;
27
+ } else if (opts.imagePath) {
28
+ this.loadBackground(opts.imagePath);
29
+ }
30
+ }
31
+
32
+ async init({ outputCanvas, inputElement: inputVideo }: VideoTransformerInitOptions) {
33
+ super.init({ outputCanvas, inputElement: inputVideo });
34
+
35
+ const fileSet = await vision.FilesetResolver.forVisionTasks(
36
+ 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/wasm',
37
+ );
38
+
39
+ this.imageSegmenter = await vision.ImageSegmenter.createFromOptions(fileSet, {
40
+ baseOptions: {
41
+ modelAssetPath:
42
+ 'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite',
43
+ delegate: 'CPU',
44
+ },
45
+ runningMode: 'VIDEO',
46
+ outputCategoryMask: true,
47
+ outputConfidenceMasks: false,
48
+ });
49
+
50
+ // this.loadBackground(opts.backgroundUrl).catch((e) => console.error(e));
51
+ this.sendFramesContinuouslyForSegmentation(this.inputVideo!);
52
+ }
53
+
54
+ async destroy() {
55
+ await super.destroy();
56
+ await this.imageSegmenter?.close();
57
+ this.backgroundImage = null;
58
+ }
59
+
60
+ async sendFramesContinuouslyForSegmentation(videoEl: HTMLVideoElement) {
61
+ if (!this.isDisabled) {
62
+ if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
63
+ let startTimeMs = performance.now();
64
+ this.imageSegmenter?.segmentForVideo(
65
+ videoEl,
66
+ startTimeMs,
67
+ (result) => (this.segmentationResults = result),
68
+ );
69
+ }
70
+ videoEl.requestVideoFrameCallback(() => {
71
+ this.sendFramesContinuouslyForSegmentation(videoEl);
72
+ });
73
+ }
74
+ }
75
+
76
+ async loadBackground(path: string) {
77
+ const img = new Image();
78
+
79
+ await new Promise((resolve, reject) => {
80
+ img.crossOrigin = 'Anonymous';
81
+ img.onload = () => resolve(img);
82
+ img.onerror = (err) => reject(err);
83
+ img.src = path;
84
+ });
85
+ const imageData = await createImageBitmap(img);
86
+ this.backgroundImage = imageData;
87
+ }
88
+
89
+ async transform(frame: VideoFrame, controller: TransformStreamDefaultController<VideoFrame>) {
90
+ if (this.isDisabled) {
91
+ controller.enqueue(frame);
92
+ return;
93
+ }
94
+ if (!this.canvas) {
95
+ throw TypeError('Canvas needs to be initialized first');
96
+ }
97
+ if (this.blurRadius) {
98
+ await this.blurBackground(frame);
99
+ } else {
100
+ await this.drawVirtualBackground(frame);
101
+ }
102
+ const newFrame = new VideoFrame(this.canvas, {
103
+ timestamp: frame.timestamp || Date.now(),
104
+ });
105
+ frame.close();
106
+ controller.enqueue(newFrame);
107
+ }
108
+
109
+ async drawVirtualBackground(frame: VideoFrame) {
110
+ if (!this.canvas || !this.ctx || !this.segmentationResults || !this.inputVideo) return;
111
+ // this.ctx.save();
112
+ // this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
113
+ if (this.segmentationResults?.categoryMask) {
114
+ this.ctx.filter = 'blur(3px)';
115
+ this.ctx.globalCompositeOperation = 'copy';
116
+ const bitmap = await maskToBitmap(
117
+ this.segmentationResults.categoryMask,
118
+ this.inputVideo.videoWidth,
119
+ this.inputVideo.videoHeight,
120
+ );
121
+ this.ctx.drawImage(bitmap, 0, 0);
122
+ this.ctx.filter = 'none';
123
+ this.ctx.globalCompositeOperation = 'source-in';
124
+ if (this.backgroundImage) {
125
+ this.ctx.drawImage(
126
+ this.backgroundImage,
127
+ 0,
128
+ 0,
129
+ this.backgroundImage.width,
130
+ this.backgroundImage.height,
131
+ 0,
132
+ 0,
133
+ this.canvas.width,
134
+ this.canvas.height,
135
+ );
136
+ } else {
137
+ this.ctx.fillStyle = '#00FF00';
138
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
139
+ }
140
+
141
+ this.ctx.globalCompositeOperation = 'destination-over';
142
+ }
143
+ this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
144
+ // this.ctx.restore();
145
+ }
146
+
147
+ async blurBackground(frame: VideoFrame) {
148
+ const start = performance.now();
149
+ if (
150
+ !this.ctx ||
151
+ !this.canvas ||
152
+ !this.segmentationResults?.categoryMask?.canvas ||
153
+ !this.inputVideo
154
+ ) {
155
+ return;
156
+ }
157
+ this.ctx.save();
158
+ this.ctx.globalCompositeOperation = 'copy';
159
+
160
+ const bitmap = await maskToBitmap(
161
+ this.segmentationResults.categoryMask,
162
+ this.inputVideo.videoWidth,
163
+ this.inputVideo.videoHeight,
164
+ );
165
+ this.ctx.filter = 'blur(3px)';
166
+ this.ctx.globalCompositeOperation = 'copy';
167
+ this.ctx.drawImage(bitmap, 0, 0);
168
+ this.ctx.filter = 'none';
169
+ this.ctx.globalCompositeOperation = 'source-out';
170
+ this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
171
+ this.ctx.globalCompositeOperation = 'destination-over';
172
+ this.ctx.filter = `blur(${this.blurRadius}px)`;
173
+ this.ctx.drawImage(frame, 0, 0);
174
+ this.ctx.restore();
175
+ console.log('draw time', performance.now() - start);
176
+ }
177
+ }
178
+
179
+ function maskToBitmap(
180
+ mask: vision.MPMask,
181
+ videoWidth: number,
182
+ videoHeight: number,
183
+ ): Promise<ImageBitmap> {
184
+ const dataArray: Uint8ClampedArray = new Uint8ClampedArray(videoWidth * videoHeight * 4);
185
+ const result = mask.getAsUint8Array();
186
+ for (let i = 0; i < result.length; i += 1) {
187
+ dataArray[i * 4] = result[i];
188
+ dataArray[i * 4 + 1] = result[i];
189
+ dataArray[i * 4 + 2] = result[i];
190
+ dataArray[i * 4 + 3] = result[i];
191
+ }
192
+ const dataNew = new ImageData(dataArray, videoWidth, videoHeight);
193
+
194
+ return createImageBitmap(dataNew);
195
+ }
@@ -0,0 +1,11 @@
1
+ import VideoTransformer from './VideoTransformer';
2
+
3
+ export default class DummyTransformer extends VideoTransformer {
4
+ async transform(frame: VideoFrame, controller: TransformStreamDefaultController<VideoFrame>) {
5
+ controller.enqueue(frame);
6
+ }
7
+
8
+ async destroy() {
9
+ // nothing to do
10
+ }
11
+ }
@@ -0,0 +1,60 @@
1
+ import { Holistic, Options, Results } from '@mediapipe/holistic';
2
+ import VideoTransformer from './VideoTransformer';
3
+ import { VideoTransformerInitOptions } from './types';
4
+
5
+ export type MediaPipeHolisticTrackerTransformerOptions = {
6
+ holisticOptions?: Options;
7
+ callback?: (results: Results) => void;
8
+ };
9
+
10
+ export default class MediaPipeHolisticTrackerTransformer extends VideoTransformer {
11
+ holistic?: Holistic;
12
+ holisticOptions: Options;
13
+ callback: (results: Results) => void;
14
+
15
+ public static get isSupported(): boolean {
16
+ return true;
17
+ }
18
+
19
+ constructor({ holisticOptions, callback }: MediaPipeHolisticTrackerTransformerOptions) {
20
+ super();
21
+ this.callback = callback || (() => null);
22
+ this.holisticOptions = holisticOptions || {};
23
+ }
24
+
25
+ init({ inputElement: inputVideo, outputCanvas }: VideoTransformerInitOptions): void {
26
+ super.init({ outputCanvas, inputElement: inputVideo });
27
+
28
+ this.holistic = new Holistic({
29
+ locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/holistic/${file}`,
30
+ });
31
+ this.holistic.setOptions(this.holisticOptions);
32
+ this.holistic.onResults((r) => {
33
+ this.callback(r);
34
+ });
35
+
36
+ this.sendFramesContinuouslyForTracking(this.inputVideo!);
37
+ }
38
+
39
+ async destroy(): Promise<void> {
40
+ this.callback = () => null;
41
+ await super.destroy();
42
+ await this.holistic?.close();
43
+ }
44
+
45
+ async transform(): Promise<void> {
46
+ return;
47
+ }
48
+
49
+ async sendFramesContinuouslyForTracking(videoEl: HTMLVideoElement): Promise<void> {
50
+ if (!this.isDisabled) {
51
+ if (videoEl.videoWidth > 0 && videoEl.videoHeight > 0) {
52
+ await this.holistic?.send({ image: videoEl });
53
+ }
54
+
55
+ videoEl.requestVideoFrameCallback(() => {
56
+ this.sendFramesContinuouslyForTracking(videoEl);
57
+ });
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,39 @@
1
+ import { VideoTrackTransformer, VideoTransformerInitOptions } from './types';
2
+
3
+ export default abstract class VideoTransformer implements VideoTrackTransformer {
4
+ transformer?: TransformStream;
5
+
6
+ canvas?: OffscreenCanvas;
7
+
8
+ ctx?: OffscreenCanvasRenderingContext2D;
9
+
10
+ inputVideo?: HTMLVideoElement;
11
+
12
+ protected isDisabled?: Boolean = false;
13
+
14
+ init({ outputCanvas, inputElement: inputVideo }: VideoTransformerInitOptions): void {
15
+ if (!(inputVideo instanceof HTMLVideoElement)) {
16
+ throw TypeError('Video transformer needs a HTMLVideoElement as input');
17
+ }
18
+ this.transformer = new TransformStream({
19
+ transform: (frame, controller) => this.transform(frame, controller),
20
+ });
21
+ this.canvas = outputCanvas || null;
22
+ if (outputCanvas) {
23
+ this.ctx = this.canvas?.getContext('2d', { readFrequently: true }) || undefined;
24
+ }
25
+ this.inputVideo = inputVideo;
26
+ this.isDisabled = false;
27
+ }
28
+
29
+ async destroy() {
30
+ this.isDisabled = true;
31
+ this.canvas = undefined;
32
+ this.ctx = undefined;
33
+ }
34
+
35
+ abstract transform(
36
+ frame: VideoFrame,
37
+ controller: TransformStreamDefaultController<VideoFrame>,
38
+ ): void;
39
+ }
@@ -0,0 +1,2 @@
1
+ export * from './BackgroundTransformer';
2
+ export * from './types';
@@ -0,0 +1,17 @@
1
+ export type TrackTransformerInitOptions = {
2
+ inputElement: HTMLMediaElement;
3
+ };
4
+
5
+ export interface VideoTransformerInitOptions extends TrackTransformerInitOptions {
6
+ outputCanvas: OffscreenCanvas;
7
+ inputElement: HTMLVideoElement;
8
+ }
9
+
10
+ export interface VideoTrackTransformer<
11
+ T extends TrackTransformerInitOptions = VideoTransformerInitOptions,
12
+ > {
13
+ init: (options: T) => void;
14
+ destroy: () => void;
15
+ transform: (frame: VideoFrame, controller: TransformStreamDefaultController) => void;
16
+ transformer?: TransformStream;
17
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const supportsProcessor = typeof MediaStreamTrackGenerator !== 'undefined';
2
+ export const supportsOffscreenCanvas = typeof OffscreenCanvas !== 'undefined';