Skip to content

Commit 69263d5

Browse files
authored
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 a1f2af5 commit 69263d5

File tree

5 files changed

+213
-142
lines changed

5 files changed

+213
-142
lines changed

packages/rrweb/src/record/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ function record<T = eventWithTime>(
320320
blockSelector,
321321
mirror,
322322
sampling: sampling?.canvas?.fps,
323+
clearWebGLBuffer: sampling?.canvas?.clearWebGLBuffer,
324+
initialSnapshotDelay: sampling?.canvas?.initialSnapshotDelay,
323325
dataURLOptions,
324326
resizeFactor: sampling?.canvas?.resizeFactor,
325327
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,
@@ -163,7 +175,12 @@ export class CanvasManager {
163175
const { id } = e.data;
164176
snapshotInProgressMap.set(id, false);
165177

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

168185
const { base64, type, dx, dy, dw, dh } = e.data;
169186
this.mutationCb({
@@ -235,58 +252,70 @@ export class CanvasManager {
235252
}
236253
lastSnapshotTime = timestamp;
237254

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

381413
rafId = requestAnimationFrame(takeSnapshots);
382414
};
383415

384-
rafId = requestAnimationFrame(takeSnapshots);
416+
const delay = setTimeout(() => {
417+
rafId = requestAnimationFrame(takeSnapshots);
418+
}, options.initialSnapshotDelay);
385419

386420
this.resetObservers = () => {
387421
canvasContextReset();
388-
cancelAnimationFrame(rafId);
422+
clearTimeout(delay);
423+
if (rafId) {
424+
cancelAnimationFrame(rafId);
425+
}
389426
};
390427
}
391428

0 commit comments

Comments
 (0)