Skip to content

Commit a85a66f

Browse files
committed
fix recording of webgl2 canvases (#106)
webgl2 canvases created after highlight would start recording with the `preserveDrawingBuffer: false` setting would not record correctly because we would snapshot a transparent image of the canvas. instead of trying to clear the canvas, we should create a context with `preserveDrawingBuffer: true` to ensure that the canvas can be recorded (tested in babylon.js) https://app.highlight.io/1/sessions/n7P0x5XTItCqEN7B7pmtJVldUzJo
1 parent 66a4ce0 commit a85a66f

File tree

5 files changed

+208
-140
lines changed

5 files changed

+208
-140
lines changed

packages/rrweb/src/record/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ function record<T = eventWithTime>(
323323
blockSelector,
324324
mirror,
325325
sampling: sampling?.canvas?.fps,
326+
clearWebGLBuffer: sampling?.canvas?.clearWebGLBuffer,
327+
initialSnapshotDelay: sampling?.canvas?.initialSnapshotDelay,
326328
dataURLOptions,
327329
resizeFactor: sampling?.canvas?.resizeFactor,
328330
maxSnapshotDimension: sampling?.canvas?.maxSnapshotDimension,

packages/rrweb/src/record/observers/canvas/canvas-manager.ts

Lines changed: 170 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export class CanvasManager {
7272
blockSelector: string | null;
7373
mirror: Mirror;
7474
sampling?: 'all' | number;
75+
clearWebGLBuffer?: boolean;
76+
initialSnapshotDelay?: number;
7577
dataURLOptions: DataURLOptions;
7678
resizeFactor?: number;
7779
maxSnapshotDimension?: number;
@@ -87,6 +89,8 @@ export class CanvasManager {
8789
blockSelector,
8890
recordCanvas,
8991
recordVideos,
92+
clearWebGLBuffer,
93+
initialSnapshotDelay,
9094
dataURLOptions,
9195
} = options;
9296
this.mutationCb = options.mutationCb;
@@ -103,6 +107,8 @@ export class CanvasManager {
103107
blockClass,
104108
blockSelector,
105109
{
110+
clearWebGLBuffer,
111+
initialSnapshotDelay,
106112
dataURLOptions,
107113
},
108114
options.resizeFactor,
@@ -111,15 +117,19 @@ export class CanvasManager {
111117
}
112118

113119
private debug(
114-
element: HTMLCanvasElement | HTMLVideoElement,
120+
element: HTMLCanvasElement | HTMLVideoElement | null,
115121
...args: Parameters<typeof console.log>
116122
) {
117123
if (!this.logger) return;
118-
let prefix = `[highlight-${element.tagName.toLowerCase()}]`;
119-
if (element.tagName.toLowerCase() === 'canvas') {
120-
prefix += ` [ctx:${(element as ICanvas).__context}]`;
124+
const id = this.mirror.getId(element);
125+
let prefix = '[highlight-canvas-manager]';
126+
if (element) {
127+
prefix = `[highlight-${element.tagName.toLowerCase()}] [id:${id}]`;
128+
if (element.tagName.toLowerCase() === 'canvas') {
129+
prefix += ` [ctx:${(element as ICanvas).__context}]`;
130+
}
121131
}
122-
this.logger.debug(prefix, element, ...args);
132+
this.logger.debug(prefix, ...args);
123133
}
124134

125135
private processMutation: canvasManagerMutationCallback = (
@@ -146,6 +156,8 @@ export class CanvasManager {
146156
blockClass: blockClass,
147157
blockSelector: string | null,
148158
options: {
159+
clearWebGLBuffer?: boolean;
160+
initialSnapshotDelay?: number;
149161
dataURLOptions: DataURLOptions;
150162
},
151163
resizeFactor?: number,
@@ -164,7 +176,12 @@ export class CanvasManager {
164176
const { id } = e.data;
165177
snapshotInProgressMap.set(id, false);
166178

167-
if (!('base64' in e.data)) return;
179+
if (!('base64' in e.data)) {
180+
this.debug(null, 'canvas worker received empty message', {
181+
status: e.data.status,
182+
});
183+
return;
184+
}
168185

169186
const { base64, type, dx, dy, dw, dh } = e.data;
170187
this.mutationCb({
@@ -236,61 +253,73 @@ export class CanvasManager {
236253
}
237254
lastSnapshotTime = timestamp;
238255

239-
const promises: Promise<void>[] = []
240-
promises.push(...getCanvas().map(async (canvas: HTMLCanvasElement) => {
241-
this.debug(canvas, 'starting snapshotting');
242-
const id = this.mirror.getId(canvas);
243-
if (snapshotInProgressMap.get(id)) {
244-
this.debug(canvas, 'snapshotting already in progress for', id);
245-
return;
246-
}
247-
snapshotInProgressMap.set(id, true);
248-
try {
249-
if (['webgl', 'webgl2'].includes((canvas as ICanvas).__context)) {
250-
// if the canvas hasn't been modified recently,
251-
// its contents won't be in memory and `createImageBitmap`
252-
// will return a transparent imageBitmap
253-
254-
const context = canvas.getContext((canvas as ICanvas).__context) as
255-
| WebGLRenderingContext
256-
| WebGL2RenderingContext
257-
| null;
256+
const promises: Promise<void>[] = [];
257+
promises.push(
258+
...getCanvas().map(async (canvas: HTMLCanvasElement) => {
259+
this.debug(canvas, 'starting snapshotting');
260+
const id = this.mirror.getId(canvas);
261+
if (snapshotInProgressMap.get(id)) {
262+
this.debug(canvas, 'snapshotting already in progress for', id);
263+
return;
264+
}
265+
snapshotInProgressMap.set(id, true);
266+
try {
258267
if (
259-
context?.getContextAttributes()?.preserveDrawingBuffer === false
268+
options.clearWebGLBuffer !== false &&
269+
['webgl', 'webgl2'].includes((canvas as ICanvas).__context)
260270
) {
261-
// Hack to load canvas back into memory so `createImageBitmap` can grab it's contents.
262-
// Context: https://twitter.com/Juice10/status/1499775271758704643
263-
// Preferably we set `preserveDrawingBuffer` to true, but that's not always possible,
271+
// if the canvas hasn't been modified recently,
272+
// its contents won't be in memory and `createImageBitmap`
273+
// will return a transparent imageBitmap
274+
275+
const context = canvas.getContext(
276+
(canvas as ICanvas).__context,
277+
) as WebGLRenderingContext | WebGL2RenderingContext | null;
278+
if (
279+
context?.getContextAttributes()?.preserveDrawingBuffer === false
280+
) {
281+
// Hack to load canvas back into memory so `createImageBitmap` can grab it's contents.
282+
// Context: https://twitter.com/Juice10/status/1499775271758704643
283+
// Preferably we set `preserveDrawingBuffer` to true, but that's not always possible,
264284
// especially when canvas is loaded before rrweb.
265285
// This hack can wipe the background color of the canvas in the (unlikely) event that
266-
// the canvas background was changed but clear was not called directly afterwards.
286+
// the canvas background was changed but clear was not called directly afterwards.
267287
// Example of this hack having negative side effect: https://visgl.github.io/react-map-gl/examples/layers
268-
context.clear(context.COLOR_BUFFER_BIT);
288+
context.clear(context?.COLOR_BUFFER_BIT);
289+
this.debug(
290+
canvas,
291+
'cleared webgl canvas to load it into memory',
292+
{ attributes: context?.getContextAttributes() },
293+
);
294+
}
269295
}
270-
}
271-
// canvas is not yet ready... this retry on the next sampling iteration.
272-
// we don't want to crash the worker if the canvas is not yet rendered.
273-
if (canvas.width === 0 || canvas.height === 0) {
274-
this.debug(canvas, 'not yet ready', {
275-
width: canvas.width,
276-
height: canvas.height,
296+
// canvas is not yet ready... this retry on the next sampling iteration.
297+
// we don't want to crash the worker by sending an undefined bitmap
298+
// if the canvas is not yet rendered.
299+
if (canvas.width === 0 || canvas.height === 0) {
300+
this.debug(canvas, 'not yet ready', {
301+
width: canvas.width,
302+
height: canvas.height,
303+
});
304+
return;
305+
}
306+
let scale = resizeFactor || 1;
307+
if (maxSnapshotDimension) {
308+
const maxDim = Math.max(canvas.width, canvas.height);
309+
scale = Math.min(scale, maxSnapshotDimension / maxDim);
310+
}
311+
const width = canvas.width * scale;
312+
const height = canvas.height * scale;
313+
314+
const bitmap = await createImageBitmap(canvas, {
315+
resizeWidth: width,
316+
resizeHeight: height,
277317
});
278-
return;
279-
}
280-
let scale = resizeFactor || 1;
281-
if (maxSnapshotDimension) {
282-
const maxDim = Math.max(canvas.width, canvas.height);
283-
scale = Math.min(scale, maxSnapshotDimension / maxDim);
284-
}
285-
const width = canvas.width * scale;
286-
const height = canvas.height * scale;
287-
288-
const bitmap = await createImageBitmap(canvas, {
289-
resizeWidth: width,
290-
resizeHeight: height,
291-
});
292-
this.debug(canvas, 'created image bitmap');
293-
worker.postMessage(
318+
this.debug(canvas, 'created image bitmap', {
319+
width: bitmap.width,
320+
height: bitmap.height,
321+
});
322+
worker.postMessage(
294323
{
295324
id,
296325
bitmap,
@@ -303,93 +332,101 @@ export class CanvasManager {
303332
dataURLOptions: options.dataURLOptions,
304333
},
305334
[bitmap],
306-
);
307-
this.debug(canvas, 'sent message');
308-
} catch (e) {
309-
this.debug(canvas, 'failed to snapshot', e);
310-
} finally {
311-
snapshotInProgressMap.set(id, false);
312-
}
313-
}))
314-
promises.push(...getVideos().map(async (video: HTMLVideoElement) => {
315-
this.debug(video, 'starting video snapshotting');
316-
const id = this.mirror.getId(video);
317-
if (snapshotInProgressMap.get(id)) {
318-
this.debug(video, 'video snapshotting already in progress for', id);
319-
return;
320-
}
321-
snapshotInProgressMap.set(id, true);
322-
try {
323-
const { width: boxWidth, height: boxHeight } =
324-
video.getBoundingClientRect();
325-
const { actualWidth, actualHeight } = {
326-
actualWidth: video.videoWidth,
327-
actualHeight: video.videoHeight,
328-
};
329-
const maxDim = Math.max(actualWidth, actualHeight);
330-
let scale = resizeFactor || 1;
331-
if (maxSnapshotDimension) {
332-
scale = Math.min(scale, maxSnapshotDimension / maxDim);
335+
);
336+
this.debug(canvas, 'sent message');
337+
} catch (e) {
338+
this.debug(canvas, 'failed to snapshot', e);
339+
} finally {
340+
snapshotInProgressMap.set(id, false);
333341
}
334-
const width = actualWidth * scale;
335-
const height = actualHeight * scale;
336-
337-
const bitmap = await createImageBitmap(video, {
338-
resizeWidth: width,
339-
resizeHeight: height,
340-
});
341-
342-
let outputScale = Math.max(boxWidth, boxHeight) / maxDim;
343-
const outputWidth = actualWidth * outputScale;
344-
const outputHeight = actualHeight * outputScale;
345-
const offsetX = (boxWidth - outputWidth) / 2;
346-
const offsetY = (boxHeight - outputHeight) / 2;
347-
this.debug(video, 'created image bitmap', {
348-
actualWidth,
349-
actualHeight,
350-
boxWidth,
351-
boxHeight,
352-
outputWidth,
353-
outputHeight,
354-
resizeWidth: width,
355-
resizeHeight: height,
356-
scale,
357-
outputScale,
358-
offsetX,
359-
offsetY,
360-
});
361-
362-
worker.postMessage(
363-
{
364-
id,
365-
bitmap,
366-
width,
367-
height,
368-
dx: offsetX,
369-
dy: offsetY,
370-
dw: outputWidth,
371-
dh: outputHeight,
372-
dataURLOptions: options.dataURLOptions,
373-
},
374-
[bitmap],
375-
);
376-
this.debug(video, 'send message');
377-
} catch (e) {
378-
this.debug(video, 'failed to snapshot', e);
379-
} finally {
380-
snapshotInProgressMap.set(id, false);
381-
}
382-
}))
383-
await Promise.all(promises)
342+
}),
343+
);
344+
promises.push(
345+
...getVideos().map(async (video: HTMLVideoElement) => {
346+
this.debug(video, 'starting video snapshotting');
347+
const id = this.mirror.getId(video);
348+
if (snapshotInProgressMap.get(id)) {
349+
this.debug(video, 'video snapshotting already in progress for', id);
350+
return;
351+
}
352+
snapshotInProgressMap.set(id, true);
353+
try {
354+
const { width: boxWidth, height: boxHeight } =
355+
video.getBoundingClientRect();
356+
const { actualWidth, actualHeight } = {
357+
actualWidth: video.videoWidth,
358+
actualHeight: video.videoHeight,
359+
};
360+
const maxDim = Math.max(actualWidth, actualHeight);
361+
let scale = resizeFactor || 1;
362+
if (maxSnapshotDimension) {
363+
scale = Math.min(scale, maxSnapshotDimension / maxDim);
364+
}
365+
const width = actualWidth * scale;
366+
const height = actualHeight * scale;
367+
368+
const bitmap = await createImageBitmap(video, {
369+
resizeWidth: width,
370+
resizeHeight: height,
371+
});
372+
373+
let outputScale = Math.max(boxWidth, boxHeight) / maxDim;
374+
const outputWidth = actualWidth * outputScale;
375+
const outputHeight = actualHeight * outputScale;
376+
const offsetX = (boxWidth - outputWidth) / 2;
377+
const offsetY = (boxHeight - outputHeight) / 2;
378+
this.debug(video, 'created image bitmap', {
379+
actualWidth,
380+
actualHeight,
381+
boxWidth,
382+
boxHeight,
383+
outputWidth,
384+
outputHeight,
385+
resizeWidth: width,
386+
resizeHeight: height,
387+
scale,
388+
outputScale,
389+
offsetX,
390+
offsetY,
391+
});
392+
393+
worker.postMessage(
394+
{
395+
id,
396+
bitmap,
397+
width,
398+
height,
399+
dx: offsetX,
400+
dy: offsetY,
401+
dw: outputWidth,
402+
dh: outputHeight,
403+
dataURLOptions: options.dataURLOptions,
404+
},
405+
[bitmap],
406+
);
407+
this.debug(video, 'send message');
408+
} catch (e) {
409+
this.debug(video, 'failed to snapshot', e);
410+
} finally {
411+
snapshotInProgressMap.set(id, false);
412+
}
413+
}),
414+
);
415+
Promise.all(promises).catch(console.error);
384416

385417
rafId = requestAnimationFrame(takeSnapshots);
386418
};
387419

388-
rafId = requestAnimationFrame(takeSnapshots);
420+
const delay = setTimeout(() => {
421+
rafId = requestAnimationFrame(takeSnapshots);
422+
}, options.initialSnapshotDelay);
389423

390424
this.resetObservers = () => {
391425
canvasContextReset();
392-
cancelAnimationFrame(rafId);
426+
clearTimeout(delay);
427+
if (rafId) {
428+
cancelAnimationFrame(rafId);
429+
}
393430
};
394431
}
395432

0 commit comments

Comments
 (0)