diff --git a/docs/recipes/canvas.md b/docs/recipes/canvas.md
index 8e5c75220c..934461ee12 100644
--- a/docs/recipes/canvas.md
+++ b/docs/recipes/canvas.md
@@ -11,6 +11,18 @@ rrweb.record({
});
```
+Alternatively enable image snapshot recording of Canvas at a maximum of 15 frames per second:
+
+```js
+rrweb.record({
+ emit(event) {},
+ recordCanvas: true,
+ sampling: {
+ canvas: 15,
+ },
+});
+```
+
Enable replaying Canvas:
```js
diff --git a/docs/recipes/canvas.zh_CN.md b/docs/recipes/canvas.zh_CN.md
index c986e0a207..59ba507205 100644
--- a/docs/recipes/canvas.zh_CN.md
+++ b/docs/recipes/canvas.zh_CN.md
@@ -12,6 +12,18 @@ rrweb.record({
});
```
+或者启用每秒 15 帧的 Canvas 图像快照记录:
+
+```js
+rrweb.record({
+ emit(event) {},
+ recordCanvas: true,
+ sampling: {
+ canvas: 15,
+ },
+});
+```
+
回放时对 Canvas 进行回放:
```js
diff --git a/docs/recipes/optimize-storage.md b/docs/recipes/optimize-storage.md
index f93fe772c4..cd5152593a 100644
--- a/docs/recipes/optimize-storage.md
+++ b/docs/recipes/optimize-storage.md
@@ -17,6 +17,7 @@ Some common patterns may emit lots of events are:
- long list
- complex SVG
- element with JS controlled animation
+- canvas animations
## Sampling
diff --git a/docs/recipes/optimize-storage.zh_CN.md b/docs/recipes/optimize-storage.zh_CN.md
index f878ac810f..c180d57ad5 100644
--- a/docs/recipes/optimize-storage.zh_CN.md
+++ b/docs/recipes/optimize-storage.zh_CN.md
@@ -17,6 +17,7 @@
- 长列表
- 复杂的 SVG
- 包含 JS 控制动画的元素
+- canvas 动画
## 抽样策略
diff --git a/guide.md b/guide.md
index b95b4c2da3..3b2e058df1 100644
--- a/guide.md
+++ b/guide.md
@@ -135,30 +135,30 @@ setInterval(save, 10 * 1000);
The parameter of `rrweb.record` accepts the following options.
-| key | default | description |
-| -------------------- | ------------------ | ------------------------------------------------------------ |
-| emit | required | the callback function to get emitted events |
-| checkoutEveryNth | - | take a full snapshot after every N events
refer to the [checkout](#checkout) chapter |
-| checkoutEveryNms | - | take a full snapshot after every N ms
refer to the [checkout](#checkout) chapter |
-| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter |
-| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter |
-| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter |
-| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter |
-| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter |
-| maskAllInputs | false | mask all input content as \* |
-| maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) |
-| maskInputFn | - | customize mask input content recording logic |
-| maskTextFn | - | customize mask text content recording logic |
-| slimDOMOptions | {} | remove unnecessary parts of the DOM
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L97-L108) |
-| inlineStylesheet | true | whether to inline the stylesheet in the events |
-| hooks | {} | hooks for events
refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
-| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
-| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
-| recordCanvas | false | whether to record the canvas element |
-| inlineImages | false | whether to record the image content |
-| collectFonts | false | whether to collect fonts in the website |
+| key | default | description |
+| -------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| emit | required | the callback function to get emitted events |
+| checkoutEveryNth | - | take a full snapshot after every N events
refer to the [checkout](#checkout) chapter |
+| checkoutEveryNms | - | take a full snapshot after every N ms
refer to the [checkout](#checkout) chapter |
+| blockClass | 'rr-block' | Use a string or RegExp to configure which elements should be blocked, refer to the [privacy](#privacy) chapter |
+| blockSelector | null | Use a string to configure which selector should be blocked, refer to the [privacy](#privacy) chapter |
+| ignoreClass | 'rr-ignore' | Use a string or RegExp to configure which elements should be ignored, refer to the [privacy](#privacy) chapter |
+| maskTextClass | 'rr-mask' | Use a string or RegExp to configure which elements should be masked, refer to the [privacy](#privacy) chapter |
+| maskTextSelector | null | Use a string to configure which selector should be masked, refer to the [privacy](#privacy) chapter |
+| maskAllInputs | false | mask all input content as \* |
+| maskInputOptions | { password: true } | mask some kinds of input \*
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L77-L95) |
+| maskInputFn | - | customize mask input content recording logic |
+| maskTextFn | - | customize mask text content recording logic |
+| slimDOMOptions | {} | remove unnecessary parts of the DOM
refer to the [list](https://github.com/rrweb-io/rrweb/blob/588164aa12f1d94576f89ae0210b98f6e971c895/packages/rrweb-snapshot/src/types.ts#L97-L108) |
+| inlineStylesheet | true | whether to inline the stylesheet in the events |
+| hooks | {} | hooks for events
refer to the [list](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
+| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
+| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
+| recordCanvas | false | Whether to record the canvas element. Available options:
`false`,
`true` |
+| inlineImages | false | whether to record the image content |
+| collectFonts | false | whether to collect fonts in the website |
| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) |
-| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) |
+| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) |
#### Privacy
@@ -286,23 +286,23 @@ replayer.pause(5000);
The replayer accepts options as its constructor's second parameter, and it has the following options:
-| key | default | description |
-| ------------------- | ------------- | ------------------------------------------------------------ |
-| speed | 1 | replay speed ratio |
-| root | document.body | the root element of replayer |
-| loadTimeout | 0 | timeout of loading remote style sheet |
-| skipInactive | false | whether to skip inactive time |
-| showWarning | true | whether to print warning messages during replay |
-| showDebug | false | whether to print debug messages during replay |
-| blockClass | 'rr-block' | element with the class name will display as a blocked area |
-| liveMode | false | whether to enable live mode |
-| insertStyleRules | [] | accepts multiple CSS rule string, which will be injected into the replay iframe |
-| triggerFocus | true | whether to trigger focus during replay |
-| UNSAFE_replayCanvas | false | whether to replay the canvas element. **Enable this will remove the sandbox, which is unsafe.** |
+| key | default | description |
+| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| speed | 1 | replay speed ratio |
+| root | document.body | the root element of replayer |
+| loadTimeout | 0 | timeout of loading remote style sheet |
+| skipInactive | false | whether to skip inactive time |
+| showWarning | true | whether to print warning messages during replay |
+| showDebug | false | whether to print debug messages during replay |
+| blockClass | 'rr-block' | element with the class name will display as a blocked area |
+| liveMode | false | whether to enable live mode |
+| insertStyleRules | [] | accepts multiple CSS rule string, which will be injected into the replay iframe |
+| triggerFocus | true | whether to trigger focus during replay |
+| UNSAFE_replayCanvas | false | whether to replay the canvas element. **Enable this will remove the sandbox, which is unsafe.** |
| mouseTail | true | whether to show mouse tail during replay. Set to false to disable mouse tail. A complete config can be found in this [type](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L407) |
-| unpackFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
-| logConfig | - | configuration of console output playback, refer to the [console recipe](./docs/recipes/console.md) |
-| plugins | [] | load plugins to provide extended replay functions. [What is plugins?](./docs/recipes/plugin.md) |
+| unpackFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
+| logConfig | - | configuration of console output playback, refer to the [console recipe](./docs/recipes/console.md) |
+| plugins | [] | load plugins to provide extended replay functions. [What is plugins?](./docs/recipes/plugin.md) |
#### Use rrweb-player
diff --git a/guide.zh_CN.md b/guide.zh_CN.md
index bdb0a93305..a987934518 100644
--- a/guide.zh_CN.md
+++ b/guide.zh_CN.md
@@ -150,7 +150,7 @@ setInterval(save, 10 * 1000);
| hooks | {} | 各类事件的回调
类型详见[列表](https://github.com/rrweb-io/rrweb/blob/9488deb6d54a5f04350c063d942da5e96ab74075/src/types.ts#L207) |
| packFn | - | 数据压缩函数,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
| sampling | - | 数据抽样策略,详见[优化存储策略](./docs/recipes/optimize-storage.zh_CN.md) |
-| recordCanvas | false | 是否记录 canvas 内容 |
+| recordCanvas | false | 是否记录 canvas 内容, 可用选项:false, true |
| inlineImages | false | 是否将图片内容记内联录制 |
| collectFonts | false | 是否记录页面中的字体文件 |
| userTriggeredOnInput | false | [什么是 `userTriggered`](https://github.com/rrweb-io/rrweb/pull/495) |
diff --git a/packages/rrweb-player/package.json b/packages/rrweb-player/package.json
index ec09c21361..55a830e42f 100644
--- a/packages/rrweb-player/package.json
+++ b/packages/rrweb-player/package.json
@@ -2,7 +2,7 @@
"name": "rrweb-player",
"version": "0.7.14",
"devDependencies": {
- "@rollup/plugin-commonjs": "^11.0.0",
+ "@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^7.0.0",
"@rollup/plugin-typescript": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^3.7.0",
diff --git a/packages/rrweb/package.json b/packages/rrweb/package.json
index 145dd1b6f9..18b32fe1b4 100644
--- a/packages/rrweb/package.json
+++ b/packages/rrweb/package.json
@@ -41,14 +41,14 @@
},
"homepage": "https://github.com/rrweb-io/rrweb#readme",
"devDependencies": {
- "@rollup/plugin-node-resolve": "^7.0.0",
- "@rollup/plugin-typescript": "^8.3.1",
+ "@rollup/plugin-node-resolve": "^13.1.3",
"@types/chai": "^4.1.6",
"@types/inquirer": "0.0.43",
"@types/jest": "^27.4.1",
"@types/jest-image-snapshot": "^4.3.1",
"@types/jsdom": "^16.2.14",
"@types/node": "^17.0.21",
+ "@types/offscreencanvas": "^2019.6.4",
"@types/prettier": "^2.3.2",
"@types/puppeteer": "^5.4.4",
"cross-env": "^5.2.0",
@@ -63,10 +63,12 @@
"jsdom-global": "^3.0.2",
"prettier": "2.2.1",
"puppeteer": "^9.1.1",
- "rollup": "^2.45.2",
+ "rollup": "^2.68.0",
"rollup-plugin-postcss": "^3.1.1",
- "rollup-plugin-rename-node-modules": "^1.1.0",
+ "rollup-plugin-rename-node-modules": "^1.3.1",
"rollup-plugin-terser": "^7.0.2",
+ "rollup-plugin-typescript2": "^0.31.2",
+ "rollup-plugin-web-worker-loader": "^1.6.1",
"ts-jest": "^27.1.3",
"ts-node": "^10.7.0",
"tslib": "^2.3.1",
diff --git a/packages/rrweb/rollup.config.js b/packages/rrweb/rollup.config.js
index 8d6a3c6633..3c2eff47a1 100644
--- a/packages/rrweb/rollup.config.js
+++ b/packages/rrweb/rollup.config.js
@@ -1,8 +1,9 @@
-import typescript from '@rollup/plugin-typescript';
+import typescript from 'rollup-plugin-typescript2';
import resolve from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
import postcss from 'rollup-plugin-postcss';
import renameNodeModules from 'rollup-plugin-rename-node-modules';
+import webWorkerLoader from 'rollup-plugin-web-worker-loader';
import pkg from './package.json';
function toRecordPath(path) {
@@ -45,6 +46,13 @@ function toMinPath(path) {
}
const baseConfigs = [
+ // all in one
+ {
+ input: './src/entries/all.ts',
+ name: 'rrweb',
+ pathFn: toAllPath,
+ esm: true,
+ },
// record only
{
input: './src/record/index.ts',
@@ -75,13 +83,6 @@ const baseConfigs = [
name: 'rrweb',
pathFn: (p) => p,
},
- // all in one
- {
- input: './src/entries/all.ts',
- name: 'rrweb',
- pathFn: toAllPath,
- esm: true,
- },
// plugins
{
input: './src/plugins/console/record/index.ts',
@@ -110,10 +111,8 @@ let configs = [];
for (const c of baseConfigs) {
const basePlugins = [
resolve({ browser: true }),
- typescript({
- // a trick to avoid @rollup/plugin-typescript error
- outDir: 'es/rrweb',
- }),
+ webWorkerLoader(),
+ typescript(),
];
const plugins = basePlugins.concat(
postcss({
@@ -200,6 +199,7 @@ if (process.env.BROWSER_ONLY) {
for (const c of browserOnlyBaseConfigs) {
const plugins = [
resolve({ browser: true }),
+ webWorkerLoader(),
typescript({
outDir: null,
}),
diff --git a/packages/rrweb/scripts/repl.js b/packages/rrweb/scripts/repl.js
index e4ad49210b..839b8d5ca6 100644
--- a/packages/rrweb/scripts/repl.js
+++ b/packages/rrweb/scripts/repl.js
@@ -169,7 +169,9 @@ function getCode() {
});
await page.evaluate(`${code}
const events = ${JSON.stringify(events)};
- const replayer = new rrweb.Replayer(events);
+ const replayer = new rrweb.Replayer(events, {
+ UNSAFE_replayCanvas: true
+ });
replayer.play();
`);
}
diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts
index 3b6d910ceb..3240e2f42a 100644
--- a/packages/rrweb/src/record/index.ts
+++ b/packages/rrweb/src/record/index.ts
@@ -221,6 +221,7 @@ function record(
win: window,
blockClass,
mirror,
+ sampling: sampling.canvas,
});
const shadowDomManager = new ShadowDomManager({
diff --git a/packages/rrweb/src/record/observers/canvas/2d.ts b/packages/rrweb/src/record/observers/canvas/2d.ts
index 6e7049af82..86cf0e396a 100644
--- a/packages/rrweb/src/record/observers/canvas/2d.ts
+++ b/packages/rrweb/src/record/observers/canvas/2d.ts
@@ -7,6 +7,7 @@ import {
listenerHandler,
} from '../../../types';
import { hookSetter, isBlocked, patch } from '../../../utils';
+import { serializeArgs } from './serialize-args';
export default function initCanvas2DMutationObserver(
cb: canvasManagerMutationCallback,
@@ -36,27 +37,10 @@ export default function initCanvas2DMutationObserver(
...args: Array
) {
if (!isBlocked(this.canvas, blockClass)) {
- // Using setTimeout as getImageData + JSON.stringify can be heavy
+ // Using setTimeout as toDataURL can be heavy
// and we'd rather not block the main thread
setTimeout(() => {
- const recordArgs = [...args];
- if (prop === 'drawImage') {
- if (
- recordArgs[0] &&
- recordArgs[0] instanceof HTMLCanvasElement
- ) {
- const canvas = recordArgs[0];
- const ctx = canvas.getContext('2d');
- let imgd = ctx?.getImageData(
- 0,
- 0,
- canvas.width,
- canvas.height,
- );
- let pix = imgd?.data;
- recordArgs[0] = JSON.stringify(pix);
- }
- }
+ const recordArgs = serializeArgs([...args], win, this);
cb(this.canvas, {
type: CanvasContext['2D'],
property: prop,
diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts
index 007419b37d..79859dbbc8 100644
--- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts
+++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts
@@ -1,16 +1,20 @@
-import { Mirror } from 'rrweb-snapshot';
+import { ICanvas, Mirror } from 'rrweb-snapshot';
import {
blockClass,
+ CanvasContext,
canvasManagerMutationCallback,
canvasMutationCallback,
canvasMutationCommand,
canvasMutationWithType,
IWindow,
listenerHandler,
+ CanvasArg,
} from '../../../types';
import initCanvas2DMutationObserver from './2d';
import initCanvasContextObserver from './canvas';
import initCanvasWebGLMutationObserver from './webgl';
+import ImageBitmapDataURLWorker from 'web-worker:../../workers/image-bitmap-data-url-worker.ts';
+import { ImageBitmapDataURLRequestWorker } from '../../workers/image-bitmap-data-url-worker';
export type RafStamps = { latestId: number; invokeId: number | null };
@@ -51,17 +55,21 @@ export class CanvasManager {
}
constructor(options: {
- recordCanvas: boolean | number;
+ recordCanvas: boolean;
mutationCb: canvasMutationCallback;
win: IWindow;
blockClass: blockClass;
mirror: Mirror;
+ sampling?: 'all' | number;
}) {
+ const { sampling = 'all', win, blockClass, recordCanvas } = options;
this.mutationCb = options.mutationCb;
this.mirror = options.mirror;
- if (options.recordCanvas === true)
- this.initCanvasMutationObserver(options.win, options.blockClass);
+ if (recordCanvas && sampling === 'all')
+ this.initCanvasMutationObserver(win, blockClass);
+ if (recordCanvas && typeof sampling === 'number')
+ this.initCanvasFPSObserver(sampling, win, blockClass);
}
private processMutation: canvasManagerMutationCallback = function (
@@ -81,6 +89,111 @@ export class CanvasManager {
this.pendingCanvasMutations.get(target)!.push(mutation);
};
+ private initCanvasFPSObserver(
+ fps: number,
+ win: IWindow,
+ blockClass: blockClass,
+ ) {
+ const canvasContextReset = initCanvasContextObserver(win, blockClass);
+ 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)) return;
+
+ const { base64, type, width, height } = e.data;
+ this.mutationCb({
+ id,
+ type: CanvasContext['2D'],
+ commands: [
+ {
+ property: 'clearRect', // wipe canvas
+ args: [0, 0, width, height],
+ },
+ {
+ property: 'drawImage', // draws (semi-transparent) image
+ args: [
+ {
+ rr_type: 'ImageBitmap',
+ args: [
+ {
+ rr_type: 'Blob',
+ data: [{ rr_type: 'ArrayBuffer', base64 }],
+ type,
+ },
+ ],
+ } as CanvasArg,
+ 0,
+ 0,
+ ],
+ },
+ ],
+ });
+ };
+
+ const timeBetweenSnapshots = 1000 / fps;
+ let lastSnapshotTime = 0;
+ let rafId: number;
+
+ const takeCanvasSnapshots = (timestamp: DOMHighResTimeStamp) => {
+ if (
+ lastSnapshotTime &&
+ timestamp - lastSnapshotTime < timeBetweenSnapshots
+ ) {
+ rafId = requestAnimationFrame(takeCanvasSnapshots);
+ return;
+ }
+ lastSnapshotTime = timestamp;
+
+ win.document
+ .querySelectorAll(`canvas:not(.${blockClass} *)`)
+ .forEach(async (canvas: HTMLCanvasElement) => {
+ const id = this.mirror.getId(canvas);
+ if (snapshotInProgressMap.get(id)) return;
+ snapshotInProgressMap.set(id, true);
+ if (['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);
+ }
+ }
+ const bitmap = await createImageBitmap(canvas);
+ worker.postMessage(
+ {
+ id,
+ bitmap,
+ width: canvas.width,
+ height: canvas.height,
+ },
+ [bitmap],
+ );
+ });
+ rafId = requestAnimationFrame(takeCanvasSnapshots);
+ };
+
+ rafId = requestAnimationFrame(takeCanvasSnapshots);
+
+ this.resetObservers = () => {
+ canvasContextReset();
+ cancelAnimationFrame(rafId);
+ };
+ }
+
private initCanvasMutationObserver(
win: IWindow,
blockClass: blockClass,
diff --git a/packages/rrweb/src/record/observers/canvas/serialize-args.ts b/packages/rrweb/src/record/observers/canvas/serialize-args.ts
index e245ee4123..98095b3a61 100644
--- a/packages/rrweb/src/record/observers/canvas/serialize-args.ts
+++ b/packages/rrweb/src/record/observers/canvas/serialize-args.ts
@@ -1,20 +1,14 @@
import { encode } from 'base64-arraybuffer';
-import { IWindow, SerializedWebGlArg } from '../../../types';
+import { IWindow, CanvasArg } from '../../../types';
// TODO: unify with `replay/webgl.ts`
-type GLVarMap = Map;
-const webGLVarMap: Map<
- WebGLRenderingContext | WebGL2RenderingContext,
- GLVarMap
-> = new Map();
-export function variableListFor(
- ctx: WebGLRenderingContext | WebGL2RenderingContext,
- ctor: string,
-) {
- let contextMap = webGLVarMap.get(ctx);
+type CanvasVarMap = Map;
+const canvasVarMap: Map = new Map();
+export function variableListFor(ctx: RenderingContext, ctor: string) {
+ let contextMap = canvasVarMap.get(ctx);
if (!contextMap) {
contextMap = new Map();
- webGLVarMap.set(ctx, contextMap);
+ canvasVarMap.set(ctx, contextMap);
}
if (!contextMap.has(ctor)) {
contextMap.set(ctor, []);
@@ -25,7 +19,7 @@ export function variableListFor(
export const saveWebGLVar = (
value: any,
win: IWindow,
- ctx: WebGL2RenderingContext | WebGLRenderingContext,
+ ctx: RenderingContext,
): number | void => {
if (
!value ||
@@ -48,8 +42,8 @@ export const saveWebGLVar = (
export function serializeArg(
value: any,
win: IWindow,
- ctx: WebGL2RenderingContext | WebGLRenderingContext,
-): SerializedWebGlArg {
+ ctx: RenderingContext,
+): CanvasArg {
if (value instanceof Array) {
return value.map((arg) => serializeArg(arg, win, ctx));
} else if (value === null) {
@@ -100,12 +94,27 @@ export function serializeArg(
rr_type: name,
src,
};
+ } else if (value instanceof HTMLCanvasElement) {
+ const name = 'HTMLImageElement';
+ // TODO: move `toDataURL` to web worker if possible
+ const src = value.toDataURL(); // heavy on large canvas
+ return {
+ rr_type: name,
+ src,
+ };
} else if (value instanceof ImageData) {
const name = value.constructor.name;
return {
rr_type: name,
args: [serializeArg(value.data, win, ctx), value.width, value.height],
};
+ // } else if (value instanceof Blob) {
+ // const name = value.constructor.name;
+ // return {
+ // rr_type: name,
+ // data: [serializeArg(await value.arrayBuffer(), win, ctx)],
+ // type: value.type,
+ // };
} else if (isInstanceOfWebGLObject(value, win) || typeof value === 'object') {
const name = value.constructor.name;
const index = saveWebGLVar(value, win, ctx) as number;
@@ -122,7 +131,7 @@ export function serializeArg(
export const serializeArgs = (
args: Array,
win: IWindow,
- ctx: WebGLRenderingContext | WebGL2RenderingContext,
+ ctx: RenderingContext,
) => {
return [...args].map((arg) => serializeArg(arg, win, ctx));
};
diff --git a/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts
new file mode 100644
index 0000000000..28cc5c8b72
--- /dev/null
+++ b/packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts
@@ -0,0 +1,77 @@
+import { encode } from 'base64-arraybuffer';
+import {
+ ImageBitmapDataURLWorkerParams,
+ ImageBitmapDataURLWorkerResponse,
+} from '../../types';
+
+const lastBlobMap: Map = new Map();
+const transparentBlobMap: Map = new Map();
+
+export interface ImageBitmapDataURLRequestWorker {
+ postMessage: (
+ message: ImageBitmapDataURLWorkerParams,
+ transfer?: [ImageBitmap],
+ ) => void;
+ onmessage: (message: MessageEvent) => void;
+}
+
+interface ImageBitmapDataURLResponseWorker {
+ onmessage:
+ | null
+ | ((message: MessageEvent) => void);
+ postMessage(e: ImageBitmapDataURLWorkerResponse): void;
+}
+
+async function getTransparentBlobFor(
+ width: number,
+ height: number,
+): Promise {
+ const id = `${width}-${height}`;
+ if (transparentBlobMap.has(id)) return transparentBlobMap.get(id)!;
+ const offscreen = new OffscreenCanvas(width, height);
+ offscreen.getContext('2d'); // creates rendering context for `converToBlob`
+ const blob = await offscreen.convertToBlob(); // takes a while
+ const arrayBuffer = await blob.arrayBuffer();
+ const base64 = encode(arrayBuffer); // cpu intensive
+ transparentBlobMap.set(id, base64);
+ return base64;
+}
+
+// `as any` because: https://github.com/Microsoft/TypeScript/issues/20595
+const worker: ImageBitmapDataURLResponseWorker = self;
+
+worker.onmessage = async function (e) {
+ if (!('OffscreenCanvas' in globalThis))
+ return worker.postMessage({ id: e.data.id });
+
+ const { id, bitmap, width, height } = e.data;
+
+ const transparentBase64 = getTransparentBlobFor(width, height);
+
+ const offscreen = new OffscreenCanvas(width, height);
+ const ctx = offscreen.getContext('2d')!;
+
+ ctx.drawImage(bitmap, 0, 0);
+ bitmap.close();
+ const blob = await offscreen.convertToBlob(); // takes a while
+ const type = blob.type;
+ const arrayBuffer = await blob.arrayBuffer();
+ const base64 = encode(arrayBuffer); // cpu intensive
+
+ // on first try we should check if canvas is transparent,
+ // no need to save it's contents in that case
+ if (!lastBlobMap.has(id) && (await transparentBase64) === base64) {
+ lastBlobMap.set(id, base64);
+ return worker.postMessage({ id });
+ }
+
+ if (lastBlobMap.get(id) === base64) return worker.postMessage({ id }); // unchanged
+ worker.postMessage({
+ id,
+ type,
+ base64,
+ width,
+ height,
+ });
+ lastBlobMap.set(id, base64);
+};
diff --git a/packages/rrweb/src/record/workers/tsconfig.json b/packages/rrweb/src/record/workers/tsconfig.json
new file mode 100644
index 0000000000..cf0e05cb8e
--- /dev/null
+++ b/packages/rrweb/src/record/workers/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["webworker"]
+ },
+ "exclude": ["workers.d.ts"]
+}
diff --git a/packages/rrweb/src/record/workers/workers.d.ts b/packages/rrweb/src/record/workers/workers.d.ts
new file mode 100644
index 0000000000..ead3d9e1f4
--- /dev/null
+++ b/packages/rrweb/src/record/workers/workers.d.ts
@@ -0,0 +1,4 @@
+declare module 'web-worker:*' {
+ const WorkerFactory: new () => Worker;
+ export default WorkerFactory;
+}
diff --git a/packages/rrweb/src/replay/canvas/2d.ts b/packages/rrweb/src/replay/canvas/2d.ts
index feeb94d8c2..f535f24bac 100644
--- a/packages/rrweb/src/replay/canvas/2d.ts
+++ b/packages/rrweb/src/replay/canvas/2d.ts
@@ -1,7 +1,8 @@
import { Replayer } from '../';
import { canvasMutationCommand } from '../../types';
+import { deserializeArg } from './deserialize-args';
-export default function canvasMutation({
+export default async function canvasMutation({
event,
mutation,
target,
@@ -13,7 +14,7 @@ export default function canvasMutation({
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
-}): void {
+}): Promise {
try {
const ctx = target.getContext('2d')!;
@@ -37,10 +38,12 @@ export default function canvasMutation({
typeof mutation.args[0] === 'string'
) {
const image = imageMap.get(event);
- mutation.args[0] = image;
original.apply(ctx, mutation.args);
} else {
- original.apply(ctx, mutation.args);
+ const args = await Promise.all(
+ mutation.args.map(deserializeArg(imageMap, ctx)),
+ );
+ original.apply(ctx, args);
}
} catch (error) {
errorHandler(mutation, error);
diff --git a/packages/rrweb/src/replay/canvas/deserialize-args.ts b/packages/rrweb/src/replay/canvas/deserialize-args.ts
new file mode 100644
index 0000000000..d5adcbbf7d
--- /dev/null
+++ b/packages/rrweb/src/replay/canvas/deserialize-args.ts
@@ -0,0 +1,92 @@
+import { decode } from 'base64-arraybuffer';
+import type { Replayer } from '../';
+import { CanvasArg, SerializedCanvasArg } from '../../types';
+
+// TODO: add ability to wipe this list
+type GLVarMap = Map;
+const webGLVarMap: Map<
+ CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext,
+ GLVarMap
+> = new Map();
+export function variableListFor(
+ ctx:
+ | CanvasRenderingContext2D
+ | WebGLRenderingContext
+ | WebGL2RenderingContext,
+ ctor: string,
+) {
+ let contextMap = webGLVarMap.get(ctx);
+ if (!contextMap) {
+ contextMap = new Map();
+ webGLVarMap.set(ctx, contextMap);
+ }
+ if (!contextMap.has(ctor)) {
+ contextMap.set(ctor, []);
+ }
+ return contextMap.get(ctor) as any[];
+}
+
+export function isSerializedArg(arg: unknown): arg is SerializedCanvasArg {
+ return Boolean(arg && typeof arg === 'object' && 'rr_type' in arg);
+}
+
+export function deserializeArg(
+ imageMap: Replayer['imageMap'],
+ ctx:
+ | CanvasRenderingContext2D
+ | WebGLRenderingContext
+ | WebGL2RenderingContext
+ | null,
+ preload?: {
+ isUnchanged: boolean;
+ },
+): (arg: CanvasArg) => Promise {
+ return async (arg: CanvasArg): Promise => {
+ if (arg && typeof arg === 'object' && 'rr_type' in arg) {
+ if (preload) preload.isUnchanged = false;
+ if (arg.rr_type === 'ImageBitmap' && 'args' in arg) {
+ const args = await deserializeArg(imageMap, ctx, preload)(arg.args);
+ return await createImageBitmap.apply(null, args);
+ } else if ('index' in arg) {
+ if (preload || ctx === null) return arg; // we are preloading, ctx is unknown
+ const { rr_type: name, index } = arg;
+ return variableListFor(ctx, name)[index];
+ } else if ('args' in arg) {
+ const { rr_type: name, args } = arg;
+ const ctor = window[name as keyof Window];
+
+ return new ctor(
+ ...(await Promise.all(
+ args.map(deserializeArg(imageMap, ctx, preload)),
+ )),
+ );
+ } else if ('base64' in arg) {
+ return decode(arg.base64);
+ } else if ('src' in arg) {
+ const image = imageMap.get(arg.src);
+ if (image) {
+ return image;
+ } else {
+ const image = new Image();
+ image.src = arg.src;
+ imageMap.set(arg.src, image);
+ return image;
+ }
+ } else if ('data' in arg && arg.rr_type === 'Blob') {
+ const blobContents = await Promise.all(
+ arg.data.map(deserializeArg(imageMap, ctx, preload)),
+ );
+ const blob = new Blob(blobContents, {
+ type: arg.type,
+ });
+ return blob;
+ }
+ } else if (Array.isArray(arg)) {
+ const result = await Promise.all(
+ arg.map(deserializeArg(imageMap, ctx, preload)),
+ );
+ return result;
+ }
+ return arg;
+ };
+}
diff --git a/packages/rrweb/src/replay/canvas/index.ts b/packages/rrweb/src/replay/canvas/index.ts
index 73411a2b10..8924b9ec31 100644
--- a/packages/rrweb/src/replay/canvas/index.ts
+++ b/packages/rrweb/src/replay/canvas/index.ts
@@ -3,48 +3,59 @@ import {
CanvasContext,
canvasMutationCommand,
canvasMutationData,
+ canvasMutationParam,
} from '../../types';
import webglMutation from './webgl';
import canvas2DMutation from './2d';
-export default function canvasMutation({
+export default async function canvasMutation({
event,
mutation,
target,
imageMap,
+ canvasEventMap,
errorHandler,
}: {
event: Parameters[0];
mutation: canvasMutationData;
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
+ canvasEventMap: Replayer['canvasEventMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
-}): void {
+}): Promise {
try {
- const mutations: canvasMutationCommand[] =
- 'commands' in mutation ? mutation.commands : [mutation];
+ let precomputedMutation: canvasMutationParam =
+ canvasEventMap.get(event) || mutation;
+
+ const commands: canvasMutationCommand[] =
+ 'commands' in precomputedMutation
+ ? precomputedMutation.commands
+ : [precomputedMutation];
if ([CanvasContext.WebGL, CanvasContext.WebGL2].includes(mutation.type)) {
- return mutations.forEach((command) => {
- webglMutation({
+ for (let i = 0; i < commands.length; i++) {
+ const command = commands[i];
+ await webglMutation({
mutation: command,
type: mutation.type,
target,
imageMap,
errorHandler,
});
- });
+ }
+ return;
}
// default is '2d' for backwards compatibility (rrweb below 1.1.x)
- return mutations.forEach((command) => {
- canvas2DMutation({
+ for (let i = 0; i < commands.length; i++) {
+ const command = commands[i];
+ await canvas2DMutation({
event,
mutation: command,
target,
imageMap,
errorHandler,
});
- });
+ }
} catch (error) {
errorHandler(mutation, error);
}
diff --git a/packages/rrweb/src/replay/canvas/webgl.ts b/packages/rrweb/src/replay/canvas/webgl.ts
index 58d323dc3d..67bffb6ff8 100644
--- a/packages/rrweb/src/replay/canvas/webgl.ts
+++ b/packages/rrweb/src/replay/canvas/webgl.ts
@@ -1,31 +1,6 @@
-import { decode } from 'base64-arraybuffer';
-import { Replayer } from '../';
-import {
- CanvasContext,
- canvasMutationCommand,
- SerializedWebGlArg,
-} from '../../types';
-
-// TODO: add ability to wipe this list
-type GLVarMap = Map;
-const webGLVarMap: Map<
- WebGLRenderingContext | WebGL2RenderingContext,
- GLVarMap
-> = new Map();
-export function variableListFor(
- ctx: WebGLRenderingContext | WebGL2RenderingContext,
- ctor: string,
-) {
- let contextMap = webGLVarMap.get(ctx);
- if (!contextMap) {
- contextMap = new Map();
- webGLVarMap.set(ctx, contextMap);
- }
- if (!contextMap.has(ctor)) {
- contextMap.set(ctor, []);
- }
- return contextMap.get(ctor) as any[];
-}
+import type { Replayer } from '../';
+import { CanvasContext, canvasMutationCommand } from '../../types';
+import { deserializeArg, variableListFor } from './deserialize-args';
function getContext(
target: HTMLCanvasElement,
@@ -72,41 +47,7 @@ function saveToWebGLVarMap(
if (!variables.includes(result)) variables.push(result);
}
-export function deserializeArg(
- imageMap: Replayer['imageMap'],
- ctx: WebGLRenderingContext | WebGL2RenderingContext,
-): (arg: SerializedWebGlArg) => any {
- return (arg: SerializedWebGlArg): any => {
- if (arg && typeof arg === 'object' && 'rr_type' in arg) {
- if ('index' in arg) {
- const { rr_type: name, index } = arg;
- return variableListFor(ctx, name)[index];
- } else if ('args' in arg) {
- const { rr_type: name, args } = arg;
- const ctor = window[name as keyof Window];
-
- return new ctor(...args.map(deserializeArg(imageMap, ctx)));
- } else if ('base64' in arg) {
- return decode(arg.base64);
- } else if ('src' in arg) {
- const image = imageMap.get(arg.src);
- if (image) {
- return image;
- } else {
- const image = new Image();
- image.src = arg.src;
- imageMap.set(arg.src, image);
- return image;
- }
- }
- } else if (Array.isArray(arg)) {
- return arg.map(deserializeArg(imageMap, ctx));
- }
- return arg;
- };
-}
-
-export default function webglMutation({
+export default async function webglMutation({
mutation,
target,
type,
@@ -118,7 +59,7 @@ export default function webglMutation({
type: CanvasContext;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
-}): void {
+}): Promise {
try {
const ctx = getContext(target, type);
if (!ctx) return;
@@ -137,7 +78,9 @@ export default function webglMutation({
mutation.property as Exclude
] as Function;
- const args = mutation.args.map(deserializeArg(imageMap, ctx));
+ const args = await Promise.all(
+ mutation.args.map(deserializeArg(imageMap, ctx)),
+ );
const result = original.apply(ctx, args);
saveToWebGLVarMap(ctx, result);
diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts
index d7027a14bf..e3724d1b14 100644
--- a/packages/rrweb/src/replay/index.ts
+++ b/packages/rrweb/src/replay/index.ts
@@ -40,6 +40,7 @@ import {
mouseMovePos,
IWindow,
canvasMutationCommand,
+ canvasMutationParam,
textMutation,
} from '../types';
import {
@@ -64,6 +65,7 @@ import {
getPositionsAndIndex,
} from './virtual-styles';
import canvasMutation from './canvas';
+import { deserializeArg } from './canvas/deserialize-args';
const SKIP_TIME_THRESHOLD = 10 * 1000;
const SKIP_TIME_INTERVAL = 5 * 1000;
@@ -123,6 +125,7 @@ export class Replayer {
private cache: BuildCache = createCache();
private imageMap: Map = new Map();
+ private canvasEventMap: Map = new Map();
private mirror: Mirror = createMirror();
@@ -854,24 +857,30 @@ export class Replayer {
/**
* pause when there are some canvas drawImage args need to be loaded
*/
- private preloadAllImages() {
+ private async preloadAllImages(): Promise {
let beforeLoadState = this.service.state;
const stateHandler = () => {
beforeLoadState = this.service.state;
};
this.emitter.on(ReplayerEvents.Start, stateHandler);
this.emitter.on(ReplayerEvents.Pause, stateHandler);
+ const promises: Promise[] = [];
for (const event of this.service.state.context.events) {
if (
event.type === EventType.IncrementalSnapshot &&
event.data.source === IncrementalSource.CanvasMutation
- )
- if ('commands' in event.data) {
- event.data.commands.forEach((c) => this.preloadImages(c, event));
- } else {
- this.preloadImages(event.data, event);
- }
+ ) {
+ promises.push(
+ this.deserializeAndPreloadCanvasEvents(event.data, event),
+ );
+ const commands =
+ 'commands' in event.data ? event.data.commands : [event.data];
+ commands.forEach((c) => {
+ this.preloadImages(c, event);
+ });
+ }
}
+ return Promise.all(promises);
}
private preloadImages(data: canvasMutationCommand, event: eventWithTime) {
@@ -886,12 +895,34 @@ export class Replayer {
let d = imgd?.data;
d = JSON.parse(data.args[0]);
ctx?.putImageData(imgd!, 0, 0);
- } else if (this.hasImageArg(data.args)) {
- this.getImageArgs(data.args).forEach((url) => {
- const image = new Image();
- image.src = url; // this preloads the image
- this.imageMap.set(url, image);
- });
+ }
+ }
+ private async deserializeAndPreloadCanvasEvents(
+ data: canvasMutationData,
+ event: eventWithTime,
+ ) {
+ if (!this.canvasEventMap.has(event)) {
+ const status = {
+ isUnchanged: true,
+ };
+ if ('commands' in data) {
+ const commands = await Promise.all(
+ data.commands.map(async (c) => {
+ const args = await Promise.all(
+ c.args.map(deserializeArg(this.imageMap, null, status)),
+ );
+ return { ...c, args };
+ }),
+ );
+ if (status.isUnchanged === false)
+ this.canvasEventMap.set(event, { ...data, commands });
+ } else {
+ const args = await Promise.all(
+ data.args.map(deserializeArg(this.imageMap, null, status)),
+ );
+ if (status.isUnchanged === false)
+ this.canvasEventMap.set(event, { ...data, args });
+ }
}
}
@@ -1282,6 +1313,7 @@ export class Replayer {
mutation: d,
target: target as HTMLCanvasElement,
imageMap: this.imageMap,
+ canvasEventMap: this.canvasEventMap,
errorHandler: this.warnCanvasMutationFailed.bind(this),
});
diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts
index acf02173aa..629df4baa5 100644
--- a/packages/rrweb/src/types.ts
+++ b/packages/rrweb/src/types.ts
@@ -201,6 +201,12 @@ export type SamplingStrategy = Partial<{
* 'last' will only record the last input value while input a sequence of chars
*/
input: 'all' | 'last';
+ /**
+ * '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.
+ */
+ canvas: 'all' | number;
}>;
export type RecordPlugin = {
@@ -416,28 +422,36 @@ export enum CanvasContext {
WebGL2,
}
-export type SerializedWebGlArg =
+export type SerializedCanvasArg =
| {
rr_type: 'ArrayBuffer';
base64: string; // base64
}
+ | {
+ rr_type: 'Blob';
+ data: Array;
+ type?: string;
+ }
| {
rr_type: string;
src: string; // url of image
}
| {
rr_type: string;
- args: SerializedWebGlArg[];
+ args: Array;
}
| {
rr_type: string;
index: number;
- }
+ };
+
+export type CanvasArg =
+ | SerializedCanvasArg
| string
| number
| boolean
| null
- | SerializedWebGlArg[];
+ | CanvasArg[];
type mouseInteractionParam = {
type: MouseInteractions;
@@ -516,6 +530,25 @@ export type canvasManagerMutationCallback = (
p: canvasMutationWithType,
) => void;
+export type ImageBitmapDataURLWorkerParams = {
+ id: number;
+ bitmap: ImageBitmap;
+ width: number;
+ height: number;
+};
+
+export type ImageBitmapDataURLWorkerResponse =
+ | {
+ id: number;
+ }
+ | {
+ id: number;
+ type: string;
+ base64: string;
+ width: number;
+ height: number;
+ };
+
export type fontParam = {
family: string;
fontSource: string;
diff --git a/packages/rrweb/test/e2e/webgl.test.ts b/packages/rrweb/test/e2e/webgl.test.ts
index 2be0d33f0c..38c4abc880 100644
--- a/packages/rrweb/test/e2e/webgl.test.ts
+++ b/packages/rrweb/test/e2e/webgl.test.ts
@@ -130,7 +130,7 @@ describe('e2e webgl', () => {
});
replayer.play(500);
`);
- await page.waitForTimeout(50);
+ await waitForRAF(page);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
@@ -165,7 +165,7 @@ describe('e2e webgl', () => {
// wait for iframe to get added and `preloadAllImages` to ge called
await page.waitForSelector('iframe');
await page.evaluate(`replayer.play(500);`);
- await page.waitForTimeout(50);
+ await waitForRAF(page);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
diff --git a/packages/rrweb/test/html/canvas-webgl-square.html b/packages/rrweb/test/html/canvas-webgl-square.html
index cbdc7ec62e..0388cd9459 100644
--- a/packages/rrweb/test/html/canvas-webgl-square.html
+++ b/packages/rrweb/test/html/canvas-webgl-square.html
@@ -32,79 +32,84 @@
}