@livekit/track-processors 0.4.0 → 0.5.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.
@@ -1,3 +1,4 @@
1
+ import { setupWebGL } from '../webgl/index';
1
2
  import { VideoTrackTransformer, VideoTransformerInitOptions } from './types';
2
3
 
3
4
  export default abstract class VideoTransformer<Options extends Record<string, unknown>>
@@ -7,10 +8,12 @@ export default abstract class VideoTransformer<Options extends Record<string, un
7
8
 
8
9
  canvas?: OffscreenCanvas;
9
10
 
10
- ctx?: OffscreenCanvasRenderingContext2D;
11
+ // ctx?: OffscreenCanvasRenderingContext2D;
11
12
 
12
13
  inputVideo?: HTMLVideoElement;
13
14
 
15
+ gl?: ReturnType<typeof setupWebGL>;
16
+
14
17
  protected isDisabled?: Boolean = false;
15
18
 
16
19
  async init({
@@ -26,7 +29,10 @@ export default abstract class VideoTransformer<Options extends Record<string, un
26
29
  });
27
30
  this.canvas = outputCanvas || null;
28
31
  if (outputCanvas) {
29
- this.ctx = this.canvas?.getContext('2d') || undefined;
32
+ // this.ctx = this.canvas?.getContext('2d') || undefined;
33
+ this.gl = setupWebGL(
34
+ this.canvas || new OffscreenCanvas(inputVideo.videoWidth, inputVideo.videoHeight),
35
+ );
30
36
  }
31
37
  this.inputVideo = inputVideo;
32
38
  this.isDisabled = false;
@@ -34,7 +40,10 @@ export default abstract class VideoTransformer<Options extends Record<string, un
34
40
 
35
41
  async restart({ outputCanvas, inputElement: inputVideo }: VideoTransformerInitOptions) {
36
42
  this.canvas = outputCanvas || null;
37
- this.ctx = this.canvas.getContext('2d') || undefined;
43
+ this.gl?.cleanup();
44
+ this.gl = setupWebGL(
45
+ this.canvas || new OffscreenCanvas(inputVideo.videoWidth, inputVideo.videoHeight),
46
+ );
38
47
 
39
48
  this.inputVideo = inputVideo;
40
49
  this.isDisabled = false;
@@ -43,7 +52,8 @@ export default abstract class VideoTransformer<Options extends Record<string, un
43
52
  async destroy() {
44
53
  this.isDisabled = true;
45
54
  this.canvas = undefined;
46
- this.ctx = undefined;
55
+ this.gl?.cleanup();
56
+ this.gl = undefined;
47
57
  }
48
58
 
49
59
  abstract transform(
package/src/utils.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /* eslint-disable @typescript-eslint/naming-convention */
1
2
  export const supportsProcessor = typeof MediaStreamTrackGenerator !== 'undefined';
2
3
  export const supportsOffscreenCanvas = typeof OffscreenCanvas !== 'undefined';
3
4
 
@@ -0,0 +1,458 @@
1
+ import { MPMask } from '@mediapipe/tasks-vision';
2
+
3
+ // Define the blur fragment shader
4
+ const blurFragmentShader = `
5
+ precision highp float;
6
+ varying vec2 texCoords;
7
+ uniform sampler2D u_texture;
8
+ uniform vec2 u_texelSize;
9
+ uniform vec2 u_direction;
10
+ uniform float u_radius;
11
+
12
+ void main() {
13
+ float sigma = u_radius;
14
+ float twoSigmaSq = 2.0 * sigma * sigma;
15
+ float totalWeight = 0.0;
16
+ vec3 result = vec3(0.0);
17
+ const int MAX_SAMPLES = 16;
18
+ int radius = int(min(float(MAX_SAMPLES), ceil(u_radius)));
19
+
20
+ for (int i = -MAX_SAMPLES; i <= MAX_SAMPLES; ++i) {
21
+ float offset = float(i);
22
+ if (abs(offset) > float(radius)) continue;
23
+ float weight = exp(-(offset * offset) / twoSigmaSq);
24
+ vec2 sampleCoord = texCoords + u_direction * u_texelSize * offset;
25
+ result += texture2D(u_texture, sampleCoord).rgb * weight;
26
+ totalWeight += weight;
27
+ }
28
+
29
+ gl_FragColor = vec4(result / totalWeight, 1.0);
30
+ }
31
+ `;
32
+
33
+ const createShaderProgram = (gl: WebGL2RenderingContext) => {
34
+ const vs = `
35
+ attribute vec2 position;
36
+ varying vec2 texCoords;
37
+
38
+ void main() {
39
+ texCoords = (position + 1.0) / 2.0;
40
+ texCoords.y = 1.0 - texCoords.y;
41
+ gl_Position = vec4(position, 0, 1.0);
42
+ }
43
+ `;
44
+
45
+ const cS = `
46
+ precision highp float;
47
+ varying vec2 texCoords;
48
+ uniform sampler2D background;
49
+ uniform sampler2D frame;
50
+ uniform sampler2D mask;
51
+ void main() {
52
+ vec4 maskTex = texture2D(mask, texCoords);
53
+ vec4 frameTex = texture2D(frame, texCoords);
54
+ vec4 bgTex = texture2D(background, texCoords);
55
+
56
+
57
+ float a = maskTex.r;
58
+
59
+ gl_FragColor = mix(bgTex, vec4(frameTex.rgb, 1.0), 1.0 - a);
60
+
61
+ }
62
+ `;
63
+
64
+ const vertexShader = gl.createShader(gl.VERTEX_SHADER);
65
+ if (!vertexShader) {
66
+ throw Error('can not create vertex shader');
67
+ }
68
+ gl.shaderSource(vertexShader, vs);
69
+ gl.compileShader(vertexShader);
70
+
71
+ // Create our fragment shader
72
+ const compositeShader = gl.createShader(gl.FRAGMENT_SHADER);
73
+ if (!compositeShader) {
74
+ throw Error('can not create fragment shader');
75
+ }
76
+ gl.shaderSource(compositeShader, cS);
77
+ gl.compileShader(compositeShader);
78
+
79
+ // Create the composite program
80
+ const compositeProgram = gl.createProgram();
81
+ if (!compositeProgram) {
82
+ throw Error('can not create composite program');
83
+ }
84
+ gl.attachShader(compositeProgram, vertexShader);
85
+ gl.attachShader(compositeProgram, compositeShader);
86
+ gl.linkProgram(compositeProgram);
87
+
88
+ let blurProgram = null;
89
+ let blurVertexShader = null;
90
+ let blurFrag = null;
91
+ let blurUniforms = null;
92
+
93
+ // Create blur shader if enabled
94
+ blurFrag = gl.createShader(gl.FRAGMENT_SHADER);
95
+ if (!blurFrag) {
96
+ throw Error('can not create blur shader');
97
+ }
98
+ gl.shaderSource(blurFrag, blurFragmentShader);
99
+ gl.compileShader(blurFrag);
100
+
101
+ // Get compile status and log errors if any
102
+ if (!gl.getShaderParameter(blurFrag, gl.COMPILE_STATUS)) {
103
+ const info = gl.getShaderInfoLog(blurFrag);
104
+ throw Error(`Failed to compile blur shader: ${info}`);
105
+ }
106
+
107
+ // Create blur program
108
+ blurVertexShader = gl.createShader(gl.VERTEX_SHADER);
109
+ if (!blurVertexShader) {
110
+ throw Error('can not create blur vertex shader');
111
+ }
112
+ gl.shaderSource(blurVertexShader, vs);
113
+ gl.compileShader(blurVertexShader);
114
+
115
+ blurProgram = gl.createProgram();
116
+ if (!blurProgram) {
117
+ throw Error('can not create blur program');
118
+ }
119
+ gl.attachShader(blurProgram, blurVertexShader);
120
+ gl.attachShader(blurProgram, blurFrag);
121
+ gl.linkProgram(blurProgram);
122
+
123
+ // Check blur program link status
124
+ if (!gl.getProgramParameter(blurProgram, gl.LINK_STATUS)) {
125
+ const info = gl.getProgramInfoLog(blurProgram);
126
+ throw Error(`Failed to link blur program: ${info}`);
127
+ }
128
+
129
+ blurUniforms = {
130
+ position: gl.getAttribLocation(blurProgram, 'position'),
131
+ texture: gl.getUniformLocation(blurProgram, 'u_texture'),
132
+ texelSize: gl.getUniformLocation(blurProgram, 'u_texelSize'),
133
+ direction: gl.getUniformLocation(blurProgram, 'u_direction'),
134
+ radius: gl.getUniformLocation(blurProgram, 'u_radius'),
135
+ };
136
+
137
+ return {
138
+ vertexShader,
139
+ compositeShader,
140
+ blurShader: blurFrag,
141
+ compositeProgram,
142
+ blurProgram,
143
+ attribLocations: {
144
+ position: gl.getAttribLocation(compositeProgram, 'position'),
145
+ },
146
+ uniformLocations: {
147
+ mask: gl.getUniformLocation(compositeProgram, 'mask')!,
148
+ frame: gl.getUniformLocation(compositeProgram, 'frame')!,
149
+ background: gl.getUniformLocation(compositeProgram, 'background')!,
150
+ },
151
+ blurUniforms,
152
+ };
153
+ };
154
+
155
+ export function initTexture(gl: WebGL2RenderingContext, texIndex: number) {
156
+ const texRef = gl.TEXTURE0 + texIndex;
157
+ gl.activeTexture(texRef);
158
+ const texture = gl.createTexture();
159
+ gl.bindTexture(gl.TEXTURE_2D, texture);
160
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
161
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
162
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
163
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
164
+ gl.bindTexture(gl.TEXTURE_2D, texture);
165
+
166
+ return texture;
167
+ }
168
+
169
+ export function createFramebuffer(
170
+ gl: WebGL2RenderingContext,
171
+ texture: WebGLTexture,
172
+ width: number,
173
+ height: number,
174
+ ) {
175
+ const framebuffer = gl.createFramebuffer();
176
+ gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
177
+
178
+ // Set the texture as the color attachment
179
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
180
+
181
+ // Ensure texture dimensions match the provided width and height
182
+ gl.bindTexture(gl.TEXTURE_2D, texture);
183
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
184
+
185
+ // Check if framebuffer is complete
186
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
187
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
188
+ throw new Error('Framebuffer not complete');
189
+ }
190
+
191
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
192
+ return framebuffer;
193
+ }
194
+
195
+ const createVertexBuffer = (gl: WebGL2RenderingContext) => {
196
+ if (!gl) {
197
+ return null;
198
+ }
199
+ const vertexBuffer = gl.createBuffer();
200
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
201
+ gl.bufferData(
202
+ gl.ARRAY_BUFFER,
203
+ new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]),
204
+ gl.STATIC_DRAW,
205
+ );
206
+ return vertexBuffer;
207
+ };
208
+
209
+ export const setupWebGL = (canvas: OffscreenCanvas) => {
210
+ const gl = canvas.getContext('webgl2', { premultipliedAlpha: false }) as WebGL2RenderingContext;
211
+
212
+ let blurRadius: number | null = null;
213
+
214
+ if (!gl) {
215
+ return undefined;
216
+ }
217
+
218
+ gl.enable(gl.BLEND);
219
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
220
+
221
+ const {
222
+ compositeProgram,
223
+ blurProgram,
224
+ attribLocations: { position: positionLocation },
225
+ uniformLocations: {
226
+ mask: maskTextureLocation,
227
+ frame: frameTextureLocation,
228
+ background: bgTextureLocation,
229
+ },
230
+ blurUniforms,
231
+ } = createShaderProgram(gl);
232
+
233
+ const bgTexture = initTexture(gl, 0);
234
+ const frameTexture = initTexture(gl, 1);
235
+ const vertexBuffer = createVertexBuffer(gl);
236
+
237
+ // Create additional textures and framebuffers for processing
238
+ let processTextures: WebGLTexture[] = [];
239
+ let processFramebuffers: WebGLFramebuffer[] = [];
240
+
241
+ // Create textures for processing (blur)
242
+ processTextures.push(initTexture(gl, 3));
243
+ processTextures.push(initTexture(gl, 4));
244
+
245
+ // Create framebuffers for processing
246
+ processFramebuffers.push(createFramebuffer(gl, processTextures[0], canvas.width, canvas.height));
247
+ processFramebuffers.push(createFramebuffer(gl, processTextures[1], canvas.width, canvas.height));
248
+
249
+ // Set up uniforms for the composite shader
250
+ gl.useProgram(compositeProgram);
251
+ gl.uniform1i(bgTextureLocation, 0);
252
+ gl.uniform1i(frameTextureLocation, 1);
253
+ gl.uniform1i(maskTextureLocation, 2);
254
+
255
+ // Store custom background image
256
+ let customBackgroundImage: ImageBitmap | null = null;
257
+
258
+ function applyBlur(sourceTexture: WebGLTexture, width: number, height: number) {
259
+ if (!blurRadius || !blurProgram || !blurUniforms) return bgTexture;
260
+
261
+ gl.useProgram(blurProgram);
262
+
263
+ // Set common attributes
264
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
265
+ gl.vertexAttribPointer(blurUniforms.position, 2, gl.FLOAT, false, 0, 0);
266
+ gl.enableVertexAttribArray(blurUniforms.position);
267
+
268
+ const texelWidth = 1.0 / width;
269
+ const texelHeight = 1.0 / height;
270
+
271
+ // First pass - horizontal blur
272
+ gl.bindFramebuffer(gl.FRAMEBUFFER, processFramebuffers[0]);
273
+ gl.viewport(0, 0, width, height);
274
+
275
+ gl.activeTexture(gl.TEXTURE0);
276
+ gl.bindTexture(gl.TEXTURE_2D, sourceTexture);
277
+ gl.uniform1i(blurUniforms.texture, 0);
278
+ gl.uniform2f(blurUniforms.texelSize, texelWidth, texelHeight);
279
+ gl.uniform2f(blurUniforms.direction, 1.0, 0.0); // Horizontal
280
+ gl.uniform1f(blurUniforms.radius, blurRadius);
281
+
282
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
283
+
284
+ // Second pass - vertical blur
285
+ gl.bindFramebuffer(gl.FRAMEBUFFER, processFramebuffers[1]);
286
+ gl.viewport(0, 0, width, height);
287
+
288
+ gl.activeTexture(gl.TEXTURE0);
289
+ gl.bindTexture(gl.TEXTURE_2D, processTextures[0]);
290
+ gl.uniform1i(blurUniforms.texture, 0);
291
+ gl.uniform2f(blurUniforms.direction, 0.0, 1.0); // Vertical
292
+
293
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
294
+
295
+ // Reset framebuffer
296
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
297
+
298
+ return processTextures[1];
299
+ }
300
+
301
+ function render(frame: VideoFrame, mask: MPMask) {
302
+ if (frame.codedWidth === 0 || mask.width === 0) {
303
+ return;
304
+ }
305
+
306
+ const width = frame.displayWidth;
307
+ const height = frame.displayHeight;
308
+
309
+ // Prepare frame texture
310
+ gl.activeTexture(gl.TEXTURE1);
311
+ gl.bindTexture(gl.TEXTURE_2D, frameTexture);
312
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, frame);
313
+
314
+ // Apply blur if enabled (and no custom background is set)
315
+ let backgroundTexture = bgTexture;
316
+
317
+ // If we have a custom background image, use that
318
+ if (customBackgroundImage) {
319
+ gl.activeTexture(gl.TEXTURE0);
320
+ gl.bindTexture(gl.TEXTURE_2D, bgTexture);
321
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, customBackgroundImage);
322
+ backgroundTexture = bgTexture;
323
+ } else if (blurRadius) {
324
+ // Otherwise, if blur is enabled, apply blur effect to the frame
325
+ backgroundTexture = applyBlur(frameTexture, width, height);
326
+ }
327
+
328
+ // Get the mask texture
329
+ const maskTexture = mask.getAsWebGLTexture();
330
+
331
+ // Render the final composite
332
+ gl.viewport(0, 0, width, height);
333
+ gl.clearColor(1.0, 1.0, 1.0, 1.0);
334
+ gl.clear(gl.COLOR_BUFFER_BIT);
335
+
336
+ gl.useProgram(compositeProgram);
337
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
338
+ gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
339
+ gl.enableVertexAttribArray(positionLocation);
340
+
341
+ // Set background texture (either original, blurred or custom)
342
+ gl.activeTexture(gl.TEXTURE0);
343
+ gl.bindTexture(gl.TEXTURE_2D, backgroundTexture);
344
+ gl.uniform1i(bgTextureLocation, 0);
345
+
346
+ // Set frame texture
347
+ gl.activeTexture(gl.TEXTURE1);
348
+ gl.bindTexture(gl.TEXTURE_2D, frameTexture);
349
+ gl.uniform1i(frameTextureLocation, 1);
350
+
351
+ // Set mask texture
352
+ gl.activeTexture(gl.TEXTURE2);
353
+ gl.bindTexture(gl.TEXTURE_2D, maskTexture);
354
+ gl.uniform1i(maskTextureLocation, 2);
355
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
356
+
357
+ mask.close();
358
+ }
359
+
360
+ /**
361
+ * Set or update the background image
362
+ * @param image The background image to use, or null to clear
363
+ */
364
+ async function setBackgroundImage(image: ImageBitmap | null) {
365
+ // Clear existing background
366
+ customBackgroundImage = null;
367
+
368
+ if (image) {
369
+ try {
370
+ // Get current canvas dimensions
371
+ const canvasWidth = canvas.width;
372
+ const canvasHeight = canvas.height;
373
+
374
+ // Calculate dimensions and crop for "cover" mode
375
+ const imgAspect = image.width / image.height;
376
+ const canvasAspect = canvasWidth / canvasHeight;
377
+
378
+ let sx = 0;
379
+ let sy = 0;
380
+ let sWidth = image.width;
381
+ let sHeight = image.height;
382
+
383
+ // For cover mode, we need to crop some parts of the image
384
+ // to ensure it covers the canvas while maintaining aspect ratio
385
+ if (imgAspect > canvasAspect) {
386
+ // Image is wider than canvas - crop the sides
387
+ sWidth = Math.round(image.height * canvasAspect);
388
+ sx = Math.round((image.width - sWidth) / 2); // Center the crop horizontally
389
+ } else if (imgAspect < canvasAspect) {
390
+ // Image is taller than canvas - crop the top/bottom
391
+ sHeight = Math.round(image.width / canvasAspect);
392
+ sy = Math.round((image.height - sHeight) / 2); // Center the crop vertically
393
+ }
394
+
395
+ // Create a new ImageBitmap with the cropped portion
396
+ const croppedImage = await createImageBitmap(image, sx, sy, sWidth, sHeight, {
397
+ resizeWidth: canvasWidth,
398
+ resizeHeight: canvasHeight,
399
+ resizeQuality: 'medium',
400
+ });
401
+
402
+ // Store the cropped and resized image
403
+ customBackgroundImage = croppedImage;
404
+
405
+ // Load the image into the texture
406
+ gl.activeTexture(gl.TEXTURE0);
407
+ gl.bindTexture(gl.TEXTURE_2D, bgTexture);
408
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, croppedImage);
409
+ } catch (error) {
410
+ console.error('Error processing background image:', error);
411
+ // Fallback to original image on error
412
+ customBackgroundImage = image;
413
+ gl.activeTexture(gl.TEXTURE0);
414
+ gl.bindTexture(gl.TEXTURE_2D, bgTexture);
415
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
416
+ }
417
+ } else {
418
+ // set the background texture to an empty 2x2 image
419
+ const emptyImage = new ImageData(2, 2);
420
+ emptyImage.data[0] = 0;
421
+ emptyImage.data[1] = 0;
422
+ emptyImage.data[2] = 0;
423
+ emptyImage.data[3] = 0;
424
+ gl.activeTexture(gl.TEXTURE0);
425
+ gl.bindTexture(gl.TEXTURE_2D, bgTexture);
426
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, emptyImage);
427
+ }
428
+ }
429
+
430
+ function setBlurRadius(radius: number | null) {
431
+ blurRadius = radius;
432
+ setBackgroundImage(null);
433
+ }
434
+
435
+ function cleanup() {
436
+ gl.deleteProgram(compositeProgram);
437
+ gl.deleteProgram(blurProgram);
438
+ gl.deleteTexture(bgTexture);
439
+ gl.deleteTexture(frameTexture);
440
+ for (const texture of processTextures) {
441
+ gl.deleteTexture(texture);
442
+ }
443
+ for (const framebuffer of processFramebuffers) {
444
+ gl.deleteFramebuffer(framebuffer);
445
+ }
446
+ gl.deleteBuffer(vertexBuffer);
447
+
448
+ // Release any ImageBitmap resources
449
+ if (customBackgroundImage) {
450
+ customBackgroundImage.close();
451
+ customBackgroundImage = null;
452
+ }
453
+ processTextures = [];
454
+ processFramebuffers = [];
455
+ }
456
+
457
+ return { render, setBackgroundImage, setBlurRadius, cleanup };
458
+ };