@livekit/track-processors 0.6.0 → 0.7.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/README.md +31 -9
- package/dist/index.js +312 -99
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +311 -98
- package/dist/index.mjs.map +1 -1
- package/dist/src/ProcessorWrapper.d.ts +12 -7
- package/dist/src/index.d.ts +51 -5
- package/dist/src/logger.d.ts +29 -0
- package/dist/src/transformers/BackgroundTransformer.d.ts +9 -4
- package/dist/src/transformers/VideoTransformer.d.ts +1 -1
- package/dist/src/transformers/types.d.ts +11 -8
- package/dist/src/webgl/index.d.ts +1 -0
- package/dist/src/webgl/shader-programs/compositeShader.d.ts +1 -0
- package/package.json +3 -3
- package/src/ProcessorWrapper.ts +63 -23
- package/src/index.ts +206 -42
- package/src/logger.ts +74 -0
- package/src/transformers/BackgroundTransformer.ts +40 -23
- package/src/transformers/VideoTransformer.ts +1 -1
- package/src/transformers/types.ts +11 -8
- package/src/webgl/index.ts +24 -11
- package/src/webgl/shader-programs/compositeShader.ts +18 -13
- package/src/webgl/utils.ts +6 -2
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as vision from '@mediapipe/tasks-vision';
|
|
2
|
+
import { getLogger, LoggerNames } from '../logger';
|
|
2
3
|
import { dependencies } from '../../package.json';
|
|
3
4
|
import VideoTransformer from './VideoTransformer';
|
|
4
|
-
import { VideoTransformerInitOptions } from './types';
|
|
5
|
+
import { TrackTransformerDestroyOptions, VideoTransformerInitOptions } from './types';
|
|
5
6
|
|
|
6
7
|
export type SegmenterOptions = Partial<vision.ImageSegmenterOptions['baseOptions']>;
|
|
7
8
|
|
|
@@ -14,6 +15,7 @@ export interface FrameProcessingStats {
|
|
|
14
15
|
export type BackgroundOptions = {
|
|
15
16
|
blurRadius?: number;
|
|
16
17
|
imagePath?: string;
|
|
18
|
+
backgroundDisabled?: boolean;
|
|
17
19
|
/** cannot be updated through the `update` method, needs a restart */
|
|
18
20
|
segmenterOptions?: SegmenterOptions;
|
|
19
21
|
/** cannot be updated through the `update` method, needs a restart */
|
|
@@ -36,7 +38,7 @@ export default class BackgroundProcessor extends VideoTransformer<BackgroundOpti
|
|
|
36
38
|
|
|
37
39
|
segmentationResults: vision.ImageSegmenterResult | undefined;
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
backgroundImageAndPath: { imageData: ImageBitmap, path: string } | null = null;
|
|
40
42
|
|
|
41
43
|
options: BackgroundOptions;
|
|
42
44
|
|
|
@@ -44,6 +46,8 @@ export default class BackgroundProcessor extends VideoTransformer<BackgroundOpti
|
|
|
44
46
|
|
|
45
47
|
isFirstFrame = true;
|
|
46
48
|
|
|
49
|
+
private log = getLogger(LoggerNames.ProcessorWrapper);
|
|
50
|
+
|
|
47
51
|
constructor(opts: BackgroundOptions) {
|
|
48
52
|
super();
|
|
49
53
|
this.options = opts;
|
|
@@ -75,45 +79,57 @@ export default class BackgroundProcessor extends VideoTransformer<BackgroundOpti
|
|
|
75
79
|
});
|
|
76
80
|
|
|
77
81
|
// Skip loading the image here if update already loaded the image below
|
|
78
|
-
if (this.options?.imagePath
|
|
79
|
-
await this.
|
|
80
|
-
|
|
82
|
+
if (this.options?.imagePath) {
|
|
83
|
+
await this.loadAndSetBackground(this.options.imagePath).catch((err) =>
|
|
84
|
+
this.log.error('Error while loading processor background image: ', err),
|
|
81
85
|
);
|
|
82
86
|
}
|
|
83
|
-
if (this.options.blurRadius) {
|
|
87
|
+
if (typeof this.options.blurRadius === 'number') {
|
|
84
88
|
this.gl?.setBlurRadius(this.options.blurRadius);
|
|
85
89
|
}
|
|
90
|
+
this.gl?.setBackgroundDisabled(this.options.backgroundDisabled ?? false);
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
async destroy() {
|
|
93
|
+
async destroy(options?: TrackTransformerDestroyOptions) {
|
|
89
94
|
await super.destroy();
|
|
90
95
|
await this.imageSegmenter?.close();
|
|
91
|
-
this.
|
|
92
|
-
|
|
96
|
+
this.backgroundImageAndPath = null;
|
|
97
|
+
|
|
98
|
+
if (!options?.willProcessorRestart) {
|
|
99
|
+
this.isFirstFrame = true;
|
|
100
|
+
}
|
|
93
101
|
}
|
|
94
102
|
|
|
95
|
-
async
|
|
96
|
-
|
|
103
|
+
async loadAndSetBackground(path: string) {
|
|
104
|
+
if (!this.backgroundImageAndPath || this.backgroundImageAndPath?.path !== path) {
|
|
105
|
+
const img = new Image();
|
|
97
106
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
img.crossOrigin = 'Anonymous';
|
|
109
|
+
img.onload = () => resolve(img);
|
|
110
|
+
img.onerror = (err) => reject(err);
|
|
111
|
+
img.src = path;
|
|
112
|
+
});
|
|
113
|
+
const imageData = await createImageBitmap(img);
|
|
114
|
+
this.backgroundImageAndPath = { imageData, path };
|
|
115
|
+
}
|
|
116
|
+
this.gl?.setBackgroundImage(this.backgroundImageAndPath.imageData);
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
async transform(frame: VideoFrame, controller: TransformStreamDefaultController<VideoFrame>) {
|
|
109
120
|
let enqueuedFrame = false;
|
|
110
121
|
try {
|
|
111
122
|
if (!(frame instanceof VideoFrame) || frame.codedWidth === 0 || frame.codedHeight === 0) {
|
|
112
|
-
|
|
123
|
+
this.log.debug('empty frame detected, ignoring');
|
|
113
124
|
return;
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
|
|
127
|
+
let skipProcessingFrame = this.isDisabled ?? this.options.backgroundDisabled ?? false;
|
|
128
|
+
if (typeof this.options.blurRadius !== 'number' && typeof this.options.imagePath !== 'string') {
|
|
129
|
+
skipProcessingFrame = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (skipProcessingFrame) {
|
|
117
133
|
controller.enqueue(frame);
|
|
118
134
|
enqueuedFrame = true;
|
|
119
135
|
return;
|
|
@@ -187,7 +203,7 @@ export default class BackgroundProcessor extends VideoTransformer<BackgroundOpti
|
|
|
187
203
|
}
|
|
188
204
|
await segmentationPromise;
|
|
189
205
|
} catch (e) {
|
|
190
|
-
|
|
206
|
+
this.log.error('Error while processing frame: ', e);
|
|
191
207
|
} finally {
|
|
192
208
|
if (!enqueuedFrame) {
|
|
193
209
|
frame.close();
|
|
@@ -200,10 +216,11 @@ export default class BackgroundProcessor extends VideoTransformer<BackgroundOpti
|
|
|
200
216
|
|
|
201
217
|
this.gl?.setBlurRadius(opts.blurRadius ?? null);
|
|
202
218
|
if (opts.imagePath) {
|
|
203
|
-
await this.
|
|
219
|
+
await this.loadAndSetBackground(opts.imagePath);
|
|
204
220
|
} else {
|
|
205
221
|
this.gl?.setBackgroundImage(null);
|
|
206
222
|
}
|
|
223
|
+
this.gl?.setBackgroundDisabled(opts.backgroundDisabled ?? false);
|
|
207
224
|
}
|
|
208
225
|
|
|
209
226
|
private async drawFrame(frame: VideoFrame) {
|
|
@@ -10,9 +10,9 @@ export interface VideoTransformerInitOptions extends TrackTransformerInitOptions
|
|
|
10
10
|
export interface AudioTransformerInitOptions extends TrackTransformerInitOptions {}
|
|
11
11
|
|
|
12
12
|
export interface VideoTrackTransformer<Options extends Record<string, unknown>>
|
|
13
|
-
extends BaseTrackTransformer<VideoTransformerInitOptions, VideoFrame> {
|
|
13
|
+
extends BaseTrackTransformer<VideoTransformerInitOptions, VideoFrame, TrackTransformerDestroyOptions> {
|
|
14
14
|
init: (options: VideoTransformerInitOptions) => void;
|
|
15
|
-
destroy: () => void;
|
|
15
|
+
destroy: (options?: TrackTransformerDestroyOptions) => void;
|
|
16
16
|
restart: (options: VideoTransformerInitOptions) => void;
|
|
17
17
|
transform: (frame: VideoFrame, controller: TransformStreamDefaultController) => void;
|
|
18
18
|
transformer?: TransformStream;
|
|
@@ -20,26 +20,29 @@ export interface VideoTrackTransformer<Options extends Record<string, unknown>>
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface AudioTrackTransformer<Options extends Record<string, unknown>>
|
|
23
|
-
extends BaseTrackTransformer<AudioTransformerInitOptions, AudioData> {
|
|
23
|
+
extends BaseTrackTransformer<AudioTransformerInitOptions, AudioData, TrackTransformerDestroyOptions> {
|
|
24
24
|
init: (options: AudioTransformerInitOptions) => void;
|
|
25
|
-
destroy: () => void;
|
|
25
|
+
destroy: (options: TrackTransformerDestroyOptions) => void;
|
|
26
26
|
restart: (options: AudioTransformerInitOptions) => void;
|
|
27
27
|
transform: (frame: AudioData, controller: TransformStreamDefaultController) => void;
|
|
28
28
|
transformer?: TransformStream;
|
|
29
29
|
update: (options: Options) => void;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export type TrackTransformerDestroyOptions = { willProcessorRestart: boolean };
|
|
33
|
+
|
|
32
34
|
export type TrackTransformer<Options extends Record<string, unknown>> =
|
|
33
35
|
| VideoTrackTransformer<Options>
|
|
34
36
|
| AudioTrackTransformer<Options>;
|
|
35
37
|
|
|
36
38
|
export interface BaseTrackTransformer<
|
|
37
|
-
|
|
39
|
+
InitOpts extends TrackTransformerInitOptions,
|
|
38
40
|
DataType extends VideoFrame | AudioData,
|
|
41
|
+
DestroyOpts extends TrackTransformerDestroyOptions = TrackTransformerDestroyOptions,
|
|
39
42
|
> {
|
|
40
|
-
init: (options:
|
|
41
|
-
destroy: () => void;
|
|
42
|
-
restart: (options:
|
|
43
|
+
init: (options: InitOpts) => void;
|
|
44
|
+
destroy: (options: DestroyOpts) => void;
|
|
45
|
+
restart: (options: InitOpts) => void;
|
|
43
46
|
transform: (frame: DataType, controller: TransformStreamDefaultController) => void;
|
|
44
47
|
transformer?: TransformStream;
|
|
45
48
|
}
|
package/src/webgl/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* - downsample the video texture in background blur scenario before applying the (gaussian) blur for better performance
|
|
5
5
|
*
|
|
6
6
|
*/
|
|
7
|
+
import { getLogger, LoggerNames} from '../logger';
|
|
7
8
|
import { applyBlur, createBlurProgram } from './shader-programs/blurShader';
|
|
8
9
|
import { createBoxBlurProgram } from './shader-programs/boxBlurShader';
|
|
9
10
|
import { createCompositeProgram } from './shader-programs/compositeShader';
|
|
@@ -16,6 +17,8 @@ import {
|
|
|
16
17
|
resizeImageToCover,
|
|
17
18
|
} from './utils';
|
|
18
19
|
|
|
20
|
+
const log = getLogger(LoggerNames.WebGl);
|
|
21
|
+
|
|
19
22
|
export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
20
23
|
const gl = canvas.getContext('webgl2', {
|
|
21
24
|
antialias: true,
|
|
@@ -27,7 +30,7 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
27
30
|
const downsampleFactor = 4;
|
|
28
31
|
|
|
29
32
|
if (!gl) {
|
|
30
|
-
|
|
33
|
+
log.error('Failed to create WebGL context');
|
|
31
34
|
return undefined;
|
|
32
35
|
}
|
|
33
36
|
|
|
@@ -42,6 +45,7 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
42
45
|
mask: maskTextureLocation,
|
|
43
46
|
frame: frameTextureLocation,
|
|
44
47
|
background: bgTextureLocation,
|
|
48
|
+
disableBackground: disableBackgroundLocation,
|
|
45
49
|
} = composite.uniformLocations;
|
|
46
50
|
|
|
47
51
|
// Create the blur program using the same vertex shader source
|
|
@@ -103,14 +107,17 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
103
107
|
createFramebuffer(gl, finalMaskTextures[1], canvas.width, canvas.height),
|
|
104
108
|
];
|
|
105
109
|
|
|
110
|
+
let backgroundImageDisabled = false;
|
|
111
|
+
|
|
106
112
|
// Set up uniforms for the composite shader
|
|
107
113
|
gl.useProgram(compositeProgram);
|
|
114
|
+
gl.uniform1i(disableBackgroundLocation, backgroundImageDisabled ? 1 : 0);
|
|
108
115
|
gl.uniform1i(bgTextureLocation, 0);
|
|
109
116
|
gl.uniform1i(frameTextureLocation, 1);
|
|
110
117
|
gl.uniform1i(maskTextureLocation, 2);
|
|
111
118
|
|
|
112
119
|
// Store custom background image
|
|
113
|
-
let customBackgroundImage: ImageBitmap | ImageData =
|
|
120
|
+
let customBackgroundImage: ImageBitmap | ImageData | null = null;
|
|
114
121
|
|
|
115
122
|
function renderFrame(frame: VideoFrame) {
|
|
116
123
|
if (frame.codedWidth === 0 || finalMaskTextures.length === 0) {
|
|
@@ -149,7 +156,7 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
149
156
|
bgBlurFrameBuffers,
|
|
150
157
|
bgBlurTextures,
|
|
151
158
|
);
|
|
152
|
-
} else {
|
|
159
|
+
} else if (customBackgroundImage) {
|
|
153
160
|
gl.activeTexture(gl.TEXTURE0);
|
|
154
161
|
gl.bindTexture(gl.TEXTURE_2D, bgTexture);
|
|
155
162
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, customBackgroundImage);
|
|
@@ -170,6 +177,7 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
170
177
|
gl.activeTexture(gl.TEXTURE0);
|
|
171
178
|
gl.bindTexture(gl.TEXTURE_2D, backgroundTexture);
|
|
172
179
|
gl.uniform1i(bgTextureLocation, 0);
|
|
180
|
+
gl.uniform1i(disableBackgroundLocation, backgroundImageDisabled ? 1 : 0);
|
|
173
181
|
|
|
174
182
|
// Set frame texture
|
|
175
183
|
gl.activeTexture(gl.TEXTURE1);
|
|
@@ -189,9 +197,10 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
189
197
|
*/
|
|
190
198
|
async function setBackgroundImage(image: ImageBitmap | null) {
|
|
191
199
|
// Clear existing background
|
|
192
|
-
customBackgroundImage =
|
|
200
|
+
customBackgroundImage = null;
|
|
193
201
|
|
|
194
202
|
if (image) {
|
|
203
|
+
customBackgroundImage = getEmptyImageData();
|
|
195
204
|
try {
|
|
196
205
|
// Resize and crop the image to cover the canvas
|
|
197
206
|
const croppedImage = await resizeImageToCover(image, canvas.width, canvas.height);
|
|
@@ -199,16 +208,16 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
199
208
|
// Store the cropped and resized image
|
|
200
209
|
customBackgroundImage = croppedImage;
|
|
201
210
|
} catch (error) {
|
|
202
|
-
|
|
211
|
+
log.error(
|
|
203
212
|
'Error processing background image, falling back to black background:',
|
|
204
213
|
error,
|
|
205
214
|
);
|
|
206
215
|
}
|
|
207
|
-
}
|
|
208
216
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
217
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
218
|
+
gl.bindTexture(gl.TEXTURE_2D, bgTexture);
|
|
219
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, customBackgroundImage);
|
|
220
|
+
}
|
|
212
221
|
}
|
|
213
222
|
|
|
214
223
|
function setBlurRadius(radius: number | null) {
|
|
@@ -216,6 +225,10 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
216
225
|
setBackgroundImage(null);
|
|
217
226
|
}
|
|
218
227
|
|
|
228
|
+
function setBackgroundDisabled(disabled: boolean) {
|
|
229
|
+
backgroundImageDisabled = disabled;
|
|
230
|
+
}
|
|
231
|
+
|
|
219
232
|
function updateMask(mask: WebGLTexture) {
|
|
220
233
|
// Use the existing applyBlur function to apply the first blur pass
|
|
221
234
|
// The second blur pass will be written to finalMaskTextures[writeMaskIndex]
|
|
@@ -282,12 +295,12 @@ export const setupWebGL = (canvas: OffscreenCanvas | HTMLCanvasElement) => {
|
|
|
282
295
|
if (customBackgroundImage instanceof ImageBitmap) {
|
|
283
296
|
customBackgroundImage.close();
|
|
284
297
|
}
|
|
285
|
-
customBackgroundImage =
|
|
298
|
+
customBackgroundImage = null;
|
|
286
299
|
}
|
|
287
300
|
bgBlurTextures = [];
|
|
288
301
|
bgBlurFrameBuffers = [];
|
|
289
302
|
finalMaskTextures = [];
|
|
290
303
|
}
|
|
291
304
|
|
|
292
|
-
return { renderFrame, updateMask, setBackgroundImage, setBlurRadius, cleanup };
|
|
305
|
+
return { renderFrame, updateMask, setBackgroundImage, setBlurRadius, setBackgroundDisabled, cleanup };
|
|
293
306
|
};
|
|
@@ -6,29 +6,33 @@ export const compositeFragmentShader = glsl`#version 300 es
|
|
|
6
6
|
precision mediump float;
|
|
7
7
|
in vec2 texCoords;
|
|
8
8
|
uniform sampler2D background;
|
|
9
|
+
uniform bool disableBackground;
|
|
9
10
|
uniform sampler2D frame;
|
|
10
11
|
uniform sampler2D mask;
|
|
11
12
|
out vec4 fragColor;
|
|
12
13
|
|
|
13
14
|
void main() {
|
|
14
|
-
|
|
15
15
|
vec4 frameTex = texture(frame, texCoords);
|
|
16
|
-
vec4 bgTex = texture(background, texCoords);
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
if (disableBackground) {
|
|
18
|
+
fragColor = frameTex;
|
|
19
|
+
} else {
|
|
20
|
+
vec4 bgTex = texture(background, texCoords);
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
float grad = length(vec2(dFdx(maskVal), dFdy(maskVal)));
|
|
22
|
+
float maskVal = texture(mask, texCoords).r;
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// Create a smooth edge around binary transition
|
|
26
|
-
float smoothAlpha = smoothstep(0.5 - grad * edgeSoftness, 0.5 + grad * edgeSoftness, maskVal);
|
|
24
|
+
// Compute screen-space gradient to detect edge sharpness
|
|
25
|
+
float grad = length(vec2(dFdx(maskVal), dFdy(maskVal)));
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
float edgeSoftness = 2.0; // higher = softer
|
|
28
|
+
|
|
29
|
+
// Create a smooth edge around binary transition
|
|
30
|
+
float smoothAlpha = smoothstep(0.5 - grad * edgeSoftness, 0.5 + grad * edgeSoftness, maskVal);
|
|
31
|
+
|
|
32
|
+
// Optional: preserve frame alpha, or override as fully opaque
|
|
33
|
+
vec4 blended = mix(bgTex, vec4(frameTex.rgb, 1.0), 1.0 - smoothAlpha);
|
|
34
|
+
fragColor = blended;
|
|
35
|
+
}
|
|
32
36
|
|
|
33
37
|
}
|
|
34
38
|
`;
|
|
@@ -51,6 +55,7 @@ export function createCompositeProgram(gl: WebGL2RenderingContext) {
|
|
|
51
55
|
mask: gl.getUniformLocation(compositeProgram, 'mask')!,
|
|
52
56
|
frame: gl.getUniformLocation(compositeProgram, 'frame')!,
|
|
53
57
|
background: gl.getUniformLocation(compositeProgram, 'background')!,
|
|
58
|
+
disableBackground: gl.getUniformLocation(compositeProgram, 'disableBackground')!,
|
|
54
59
|
stepWidth: gl.getUniformLocation(compositeProgram, 'u_stepWidth')!,
|
|
55
60
|
};
|
|
56
61
|
|
package/src/webgl/utils.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { getLogger, LoggerNames} from '../logger';
|
|
2
|
+
|
|
3
|
+
const log = getLogger(LoggerNames.WebGl);
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Initialize a WebGL texture
|
|
3
7
|
*/
|
|
@@ -24,7 +28,7 @@ export function createShader(
|
|
|
24
28
|
gl.shaderSource(shader, source);
|
|
25
29
|
gl.compileShader(shader);
|
|
26
30
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
27
|
-
|
|
31
|
+
log.error('Shader compile failed:', gl.getShaderInfoLog(shader));
|
|
28
32
|
gl.deleteShader(shader);
|
|
29
33
|
throw new Error('Shader compile failed');
|
|
30
34
|
}
|
|
@@ -41,7 +45,7 @@ export function createProgram(
|
|
|
41
45
|
gl.attachShader(program, fs);
|
|
42
46
|
gl.linkProgram(program);
|
|
43
47
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
44
|
-
|
|
48
|
+
log.error('Program link failed:', gl.getProgramInfoLog(program));
|
|
45
49
|
throw new Error('Program link failed');
|
|
46
50
|
}
|
|
47
51
|
return program;
|