diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index b5b1ceb2..b177ac3b 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -320,6 +320,7 @@ function record( blockSelector, mirror, sampling: sampling?.canvas?.fps, + samplingManual: sampling?.canvas?.fpsManual, clearWebGLBuffer: sampling?.canvas?.clearWebGLBuffer, initialSnapshotDelay: sampling?.canvas?.initialSnapshotDelay, dataURLOptions, @@ -668,6 +669,13 @@ record.takeFullSnapshot = (isCheckout?: boolean) => { takeFullSnapshot(isCheckout); }; +record.snapshotCanvas = async (element: HTMLCanvasElement) => { + if (!canvasManager) { + throw new Error('canvas manager is not initialized'); + } + await canvasManager.snapshot(element); +}; + record.mirror = mirror; export default record; diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index d57bce9c..31f82f63 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -28,6 +28,27 @@ type pendingCanvasMutationsMap = Map< canvasMutationWithType[] >; +interface Options { + recordCanvas: boolean; + recordVideos: boolean; + mutationCb: canvasMutationCallback; + win: IWindow; + blockClass: blockClass; + blockSelector: string | null; + mirror: Mirror; + sampling?: 'all' | number; + samplingManual?: number; + clearWebGLBuffer?: boolean; + initialSnapshotDelay?: number; + dataURLOptions: DataURLOptions; + resizeFactor?: number; + maxSnapshotDimension?: number; + logger?: { + debug: (...args: Parameters) => void; + warn: (...args: Parameters) => void; + }; +} + export class CanvasManager { private pendingCanvasMutations: pendingCanvasMutationsMap = new Map(); private rafStamps: RafStamps = { latestId: 0, invokeId: null }; @@ -36,6 +57,10 @@ export class CanvasManager { debug: (...args: Parameters) => void; warn: (...args: Parameters) => void; }; + private worker: ImageBitmapDataURLRequestWorker; + private snapshotInProgressMap: Map = new Map(); + private lastSnapshotTime: Map = new Map(); + private options: Options; private mutationCb: canvasMutationCallback; private resetObservers?: listenerHandler; @@ -63,33 +88,14 @@ export class CanvasManager { this.locked = false; } - constructor(options: { - recordCanvas: boolean; - recordVideos: boolean; - mutationCb: canvasMutationCallback; - win: IWindow; - blockClass: blockClass; - blockSelector: string | null; - mirror: Mirror; - sampling?: 'all' | number; - clearWebGLBuffer?: boolean; - initialSnapshotDelay?: number; - dataURLOptions: DataURLOptions; - resizeFactor?: number; - maxSnapshotDimension?: number; - logger?: { - debug: (...args: Parameters) => void; - warn: (...args: Parameters) => void; - }; - }) { + constructor(options: Options) { const { - sampling = 'all', + sampling, win, blockClass, blockSelector, recordCanvas, recordVideos, - clearWebGLBuffer, initialSnapshotDelay, dataURLOptions, } = options; @@ -97,23 +103,73 @@ export class CanvasManager { this.mirror = options.mirror; this.logger = options.logger; - if (recordCanvas && sampling === 'all') + this.worker = + new ImageBitmapDataURLWorker() as ImageBitmapDataURLRequestWorker; + this.worker.onmessage = (e) => { + const { id } = e.data; + this.snapshotInProgressMap.set(id, false); + + if (!('base64' in e.data)) { + this.debug(null, 'canvas worker received empty message', { + data: e.data, + status: e.data.status, + }); + return; + } + + const { base64, type, dx, dy, dw, dh } = e.data; + this.mutationCb({ + id, + type: CanvasContext['2D'], + commands: [ + { + property: 'clearRect', // wipe canvas + args: [dx, dy, dw, dh], + }, + { + property: 'drawImage', // draws (semi-transparent) image + args: [ + { + rr_type: 'ImageBitmap', + args: [ + { + rr_type: 'Blob', + data: [{ rr_type: 'ArrayBuffer', base64 }], + type, + }, + ], + } as CanvasArg, + dx, + dy, + dw, + dh, + ], + }, + ], + }); + }; + + this.options = options; + + if (recordCanvas && sampling === 'all') { + this.debug(null, 'initializing canvas mutation observer', { sampling }); this.initCanvasMutationObserver(win, blockClass, blockSelector); - if (recordCanvas && typeof sampling === 'number') + } else if (recordCanvas && typeof sampling === 'number') { + this.debug(null, 'initializing canvas fps observer', { sampling }); this.initCanvasFPSObserver( recordVideos, - sampling, + sampling as number, win, blockClass, blockSelector, { - clearWebGLBuffer, initialSnapshotDelay, dataURLOptions, }, options.resizeFactor, options.maxSnapshotDimension, ); + } } private debug( @@ -149,6 +205,101 @@ export class CanvasManager { this.pendingCanvasMutations.get(target)!.push(mutation); }; + public snapshot = async (element: HTMLCanvasElement) => { + const id = this.mirror.getId(element); + if (this.snapshotInProgressMap.get(id)) { + this.debug(element, 'snapshotting already in progress for', id); + return; + } + const timeBetweenSnapshots = + 1000 / + (typeof this.options.samplingManual === 'number' + ? this.options.samplingManual + : 1); + const lastSnapshotTime = this.lastSnapshotTime.get(id); + if ( + lastSnapshotTime && + new Date().getTime() - lastSnapshotTime < timeBetweenSnapshots + ) { + return; + } + this.debug(element, 'starting snapshotting'); + this.lastSnapshotTime.set(id, new Date().getTime()); + this.snapshotInProgressMap.set(id, true); + try { + if ( + // disable buffer clearing in manual snapshot mode + this.options.samplingManual === undefined && + this.options.clearWebGLBuffer !== false && + ['webgl', 'webgl2'].includes((element as ICanvas).__context) + ) { + // if the canvas hasn't been modified recently, + // its contents won't be in memory and `createImageBitmap` + // will return a transparent imageBitmap + + const context = element.getContext((element as ICanvas).__context) as + | WebGLRenderingContext + | WebGL2RenderingContext + | null; + if (context?.getContextAttributes()?.preserveDrawingBuffer === false) { + // Hack to load canvas back into memory so `createImageBitmap` can grab it's contents. + // Context: https://twitter.com/Juice10/status/1499775271758704643 + // This hack might change the background color of the canvas in the unlikely event that + // the canvas background was changed but clear was not called directly afterwards. + context?.clear(context?.COLOR_BUFFER_BIT); + this.debug(element, 'cleared webgl canvas to load it into memory', { + attributes: context?.getContextAttributes(), + }); + } + } + // canvas is not yet ready... this retry on the next sampling iteration. + // we don't want to crash the worker by sending an undefined bitmap + // if the canvas is not yet rendered. + if (element.width === 0 || element.height === 0) { + this.debug(element, 'not yet ready', { + width: element.width, + height: element.height, + }); + return; + } + let scale = this.options.resizeFactor || 1; + if (this.options.maxSnapshotDimension) { + const maxDim = Math.max(element.width, element.height); + scale = Math.min(scale, this.options.maxSnapshotDimension / maxDim); + } + const width = element.width * scale; + const height = element.height * scale; + + const bitmap = await createImageBitmap(element, { + resizeWidth: width, + resizeHeight: height, + }); + this.debug(element, 'created image bitmap', { + width: bitmap.width, + height: bitmap.height, + }); + this.worker.postMessage( + { + id, + bitmap, + width, + height, + dx: 0, + dy: 0, + dw: element.width, + dh: element.height, + dataURLOptions: this.options.dataURLOptions, + }, + [bitmap], + ); + this.debug(element, 'sent message'); + } catch (e) { + this.debug(element, 'failed to snapshot', e); + } finally { + this.snapshotInProgressMap.set(id, false); + } + }; + private initCanvasFPSObserver( recordVideos: boolean, fps: number, @@ -156,7 +307,6 @@ export class CanvasManager { blockClass: blockClass, blockSelector: string | null, options: { - clearWebGLBuffer?: boolean; initialSnapshotDelay?: number; dataURLOptions: DataURLOptions; }, @@ -168,52 +318,6 @@ export class CanvasManager { blockClass, blockSelector, ); - const snapshotInProgressMap: Map = new Map(); - const worker = - new ImageBitmapDataURLWorker() as ImageBitmapDataURLRequestWorker; - worker.onmessage = (e) => { - const { id } = e.data; - snapshotInProgressMap.set(id, false); - - if (!('base64' in e.data)) { - this.debug(null, 'canvas worker received empty message', { - data: e.data, - status: e.data.status, - }); - return; - } - - const { base64, type, dx, dy, dw, dh } = e.data; - this.mutationCb({ - id, - type: CanvasContext['2D'], - commands: [ - { - property: 'clearRect', // wipe canvas - args: [dx, dy, dw, dh], - }, - { - property: 'drawImage', // draws (semi-transparent) image - args: [ - { - rr_type: 'ImageBitmap', - args: [ - { - rr_type: 'Blob', - data: [{ rr_type: 'ArrayBuffer', base64 }], - type, - }, - ], - } as CanvasArg, - dx, - dy, - dw, - dh, - ], - }, - ], - }); - }; const timeBetweenSnapshots = 1000 / fps; let lastSnapshotTime = 0; @@ -282,89 +386,7 @@ export class CanvasManager { promises.push( ...getCanvas(timestamp) .filter(filterElementStartTime) - .map(async (canvas: HTMLCanvasElement) => { - this.debug(canvas, 'starting snapshotting'); - const id = this.mirror.getId(canvas); - if (snapshotInProgressMap.get(id)) { - this.debug(canvas, 'snapshotting already in progress for', id); - return; - } - snapshotInProgressMap.set(id, true); - try { - if ( - options.clearWebGLBuffer !== false && - ['webgl', 'webgl2'].includes((canvas as ICanvas).__context) - ) { - // if the canvas hasn't been modified recently, - // its contents won't be in memory and `createImageBitmap` - // will return a transparent imageBitmap - - const context = canvas.getContext( - (canvas as ICanvas).__context, - ) as WebGLRenderingContext | WebGL2RenderingContext | null; - if ( - context?.getContextAttributes()?.preserveDrawingBuffer === - false - ) { - // Hack to load canvas back into memory so `createImageBitmap` can grab it's contents. - // Context: https://twitter.com/Juice10/status/1499775271758704643 - // This hack might change the background color of the canvas in the unlikely event that - // the canvas background was changed but clear was not called directly afterwards. - context?.clear(context?.COLOR_BUFFER_BIT); - this.debug( - canvas, - 'cleared webgl canvas to load it into memory', - { attributes: context?.getContextAttributes() }, - ); - } - } - // canvas is not yet ready... this retry on the next sampling iteration. - // we don't want to crash the worker by sending an undefined bitmap - // if the canvas is not yet rendered. - if (canvas.width === 0 || canvas.height === 0) { - this.debug(canvas, 'not yet ready', { - width: canvas.width, - height: canvas.height, - }); - return; - } - let scale = resizeFactor || 1; - if (maxSnapshotDimension) { - const maxDim = Math.max(canvas.width, canvas.height); - scale = Math.min(scale, maxSnapshotDimension / maxDim); - } - const width = canvas.width * scale; - const height = canvas.height * scale; - - const bitmap = await createImageBitmap(canvas, { - resizeWidth: width, - resizeHeight: height, - }); - this.debug(canvas, 'created image bitmap', { - width: bitmap.width, - height: bitmap.height, - }); - worker.postMessage( - { - id, - bitmap, - width, - height, - dx: 0, - dy: 0, - dw: canvas.width, - dh: canvas.height, - dataURLOptions: options.dataURLOptions, - }, - [bitmap], - ); - this.debug(canvas, 'sent message'); - } catch (e) { - this.debug(canvas, 'failed to snapshot', e); - } finally { - snapshotInProgressMap.set(id, false); - } - }), + .map(this.snapshot), ); promises.push( ...getVideos(timestamp) @@ -372,7 +394,7 @@ export class CanvasManager { .map(async (video: HTMLVideoElement) => { this.debug(video, 'starting video snapshotting'); const id = this.mirror.getId(video); - if (snapshotInProgressMap.get(id)) { + if (this.snapshotInProgressMap.get(id)) { this.debug( video, 'video snapshotting already in progress for', @@ -380,7 +402,7 @@ export class CanvasManager { ); return; } - snapshotInProgressMap.set(id, true); + this.snapshotInProgressMap.set(id, true); try { const { width: boxWidth, height: boxHeight } = video.getBoundingClientRect(); @@ -421,7 +443,7 @@ export class CanvasManager { offsetY, }); - worker.postMessage( + this.worker.postMessage( { id, bitmap, @@ -439,22 +461,18 @@ export class CanvasManager { } catch (e) { this.debug(video, 'failed to snapshot', e); } finally { - snapshotInProgressMap.set(id, false); + this.snapshotInProgressMap.set(id, false); } }), ); - Promise.all(promises).catch(console.error); + await Promise.all(promises).catch(console.error); rafId = requestAnimationFrame(takeSnapshots); }; - const delay = setTimeout(() => { - rafId = requestAnimationFrame(takeSnapshots); - }, 0); - + rafId = requestAnimationFrame(takeSnapshots); this.resetObservers = () => { canvasContextReset(); - clearTimeout(delay); if (rafId) { cancelAnimationFrame(rafId); } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 96344c65..e2666a5d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -194,6 +194,12 @@ export type CanvasSamplingStrategy = Partial<{ * Number only supported where [`OffscreenCanvas`](http://mdn.io/offscreencanvas) is supported. */ fps: 'all' | number; + /** + * 'all' will record every single canvas call + * number between 1 and 60, will record an image snapshots in a web-worker a (maximum) number of times per second. + * Number only supported where [`OffscreenCanvas`](http://mdn.io/offscreencanvas) is supported. + */ + fpsManual: number; /** * A scaling to apply to canvas shapshotting. Adjusts the resolution at which * canvases are recorded by this multiple.