Record canvas snapshots N times per second (#859)

* Only record canvas when recordCanvas is true

* All should be compiled first

Makes recompiling+debugging a lot faster

* Add support for compiling web workes

Replaces @rollup/plugin-typescript for rollup-plugin-typescript2 as the former is incompatible with rollup-plugin-web-worker-loader

* Update yarn.lock

* Upgrade to typescript 4.5.5

* add support for replay of ImageBitmap in 2d canvas

* Snapshot canvases in a web-worker on FPS basis

* Fix performance of canvas recording and playback

* Wait for all images to be preloaded before checking results

* flatten base64 strings, as encoding isn't consistent

* Cleanup

* Add serializing to 2d canvases as well

* Disable blob serialize test

We don't have any code for it yet

* Upgrade @rollup/plugin-commonjs to 21.0.2

Fixes
https://linguinecode.com/post/import-export-appear-at-the-top-level

* Move canvas recording options to `sampling`

Based on: https://github.com/rrweb-io/rrweb/pull/859#discussion_r846582146
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent bff6a24ddf
commit a5ff2fc77f
43 changed files with 1184 additions and 335 deletions

View File

@@ -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();

View File

@@ -32,79 +32,84 @@
}
</script>
<script>
// example from https://www.creativebloq.com/javascript/get-started-webgl-draw-square-7112981
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('webgl2');
ctx.viewport(0, 0, canvas.width, canvas.height);
ctx.clearColor(0, 0.5, 0, 1);
ctx.clear(ctx.COLOR_BUFFER_BIT);
setTimeout(() => {
// example from https://www.creativebloq.com/javascript/get-started-webgl-draw-square-7112981
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('webgl2');
ctx.viewport(0, 0, canvas.width, canvas.height);
ctx.clearColor(0, 0.5, 0, 1);
ctx.clear(ctx.COLOR_BUFFER_BIT);
const v = document.getElementById('vertex').firstChild.nodeValue;
const f = document.getElementById('fragment').firstChild.nodeValue;
const v = document.getElementById('vertex').firstChild.nodeValue;
const f = document.getElementById('fragment').firstChild.nodeValue;
const vs = ctx.createShader(ctx.VERTEX_SHADER);
ctx.shaderSource(vs, v);
ctx.compileShader(vs);
const vs = ctx.createShader(ctx.VERTEX_SHADER);
ctx.shaderSource(vs, v);
ctx.compileShader(vs);
const fs = ctx.createShader(ctx.FRAGMENT_SHADER);
ctx.shaderSource(fs, f);
ctx.compileShader(fs);
const fs = ctx.createShader(ctx.FRAGMENT_SHADER);
ctx.shaderSource(fs, f);
ctx.compileShader(fs);
program = ctx.createProgram();
ctx.attachShader(program, vs);
ctx.attachShader(program, fs);
ctx.linkProgram(program);
program = ctx.createProgram();
ctx.attachShader(program, vs);
ctx.attachShader(program, fs);
ctx.linkProgram(program);
if (!ctx.getShaderParameter(vs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(vs));
if (!ctx.getShaderParameter(vs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(vs));
if (!ctx.getShaderParameter(fs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(fs));
if (!ctx.getShaderParameter(fs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(fs));
if (!ctx.getProgramParameter(program, ctx.LINK_STATUS))
console.log(ctx.getProgramInfoLog(program));
if (!ctx.getProgramParameter(program, ctx.LINK_STATUS))
console.log(ctx.getProgramInfoLog(program));
const aspect = canvas.width / canvas.height;
const aspect = canvas.width / canvas.height;
const vertices = new Float32Array([
-0.5,
0.5 * aspect,
0.5,
0.5 * aspect,
0.5,
-0.5 * aspect, // Triangle 1
-0.5,
0.5 * aspect,
0.5,
-0.5 * aspect,
-0.5,
-0.5 * aspect, // Triangle 2
]);
const vertices = new Float32Array([
-0.5,
0.5 * aspect,
0.5,
0.5 * aspect,
0.5,
-0.5 * aspect, // Triangle 1
-0.5,
0.5 * aspect,
0.5,
-0.5 * aspect,
-0.5,
-0.5 * aspect, // Triangle 2
]);
vbuffer = ctx.createBuffer();
ctx.bindBuffer(ctx.ARRAY_BUFFER, vbuffer);
ctx.bufferData(ctx.ARRAY_BUFFER, vertices, ctx.STATIC_DRAW);
vbuffer = ctx.createBuffer();
ctx.bindBuffer(ctx.ARRAY_BUFFER, vbuffer);
ctx.bufferData(ctx.ARRAY_BUFFER, vertices, ctx.STATIC_DRAW);
itemSize = 2;
numItems = vertices.length / itemSize;
itemSize = 2;
numItems = vertices.length / itemSize;
ctx.useProgram(program);
ctx.useProgram(program);
const uColor = ctx.getUniformLocation(program, 'uColor');
ctx.uniform4fv(uColor, [0.0, 0.3, 0.0, 1.0]);
const uColor = ctx.getUniformLocation(program, 'uColor');
ctx.uniform4fv(uColor, [0.0, 0.3, 0.0, 1.0]);
const aVertexPosition = ctx.getAttribLocation(program, 'aVertexPosition');
ctx.enableVertexAttribArray(aVertexPosition);
ctx.vertexAttribPointer(
aVertexPosition,
itemSize,
ctx.FLOAT,
false,
0,
0,
);
const aVertexPosition = ctx.getAttribLocation(
program,
'aVertexPosition',
);
ctx.enableVertexAttribArray(aVertexPosition);
ctx.vertexAttribPointer(
aVertexPosition,
itemSize,
ctx.FLOAT,
false,
0,
0,
);
ctx.drawArrays(ctx.TRIANGLES, 0, numItems);
ctx.drawArrays(ctx.TRIANGLES, 0, numItems);
}, 1);
</script>
</body>
</html>

View File

@@ -11,7 +11,7 @@ import {
IncrementalSource,
styleSheetRuleData,
} from '../src/types';
import { assertSnapshot, launchPuppeteer } from './utils';
import { assertSnapshot, launchPuppeteer, waitForRAF } from './utils';
interface ISuite {
code: string;
@@ -368,7 +368,7 @@ describe('record iframes', function (this: ISuite) {
emit: ((window as unknown) as IWindow).emit,
});
});
await ctx.page.waitForTimeout(10);
await waitForRAF(ctx.page);
// console.log(JSON.stringify(ctx.events));
expect(ctx.events.length).toEqual(3);

View File

@@ -1,5 +1,164 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`record webgl recordCanvas FPS should record snapshots 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 0,
\\"commands\\": [
{
\\"property\\": \\"clearRect\\",
\\"args\\": [
0,
0,
300,
150
]
},
{
\\"property\\": \\"drawImage\\",
\\"args\\": [
{
\\"rr_type\\": \\"ImageBitmap\\",
\\"args\\": [
{
\\"rr_type\\": \\"Blob\\",
\\"data\\": [
{
\\"rr_type\\": \\"ArrayBuffer\\",
\\"base64\\": \\"base64-0\\"
}
],
\\"type\\": \\"image/png\\"
}
]
},
0,
0
]
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 0,
\\"commands\\": [
{
\\"property\\": \\"clearRect\\",
\\"args\\": [
0,
0,
300,
150
]
},
{
\\"property\\": \\"drawImage\\",
\\"args\\": [
{
\\"rr_type\\": \\"ImageBitmap\\",
\\"args\\": [
{
\\"rr_type\\": \\"Blob\\",
\\"data\\": [
{
\\"rr_type\\": \\"ArrayBuffer\\",
\\"base64\\": \\"base64-1\\"
}
],
\\"type\\": \\"image/png\\"
}
]
},
0,
0
]
}
]
}
}
]"
`;
exports[`record webgl should batch events by RAF 1`] = `
"[
{

View File

@@ -149,6 +149,16 @@ describe('serializeArg', () => {
});
});
it('should support HTMLCanvasElements saved to image', async () => {
const canvas = document.createElement('canvas');
// polyfill canvas.toDataURL as it doesn't exist in jsdom
canvas.toDataURL = () => 'data:image/png;base64,...';
expect(serializeArg(canvas, window, context)).toMatchObject({
rr_type: 'HTMLImageElement',
src: 'data:image/png;base64,...',
});
});
it('should serialize ImageData', async () => {
const arr = new Uint8ClampedArray(40000);
@@ -176,4 +186,19 @@ describe('serializeArg', () => {
],
});
});
// we do not yet support async serializing which is needed to call Blob.arrayBuffer()
it.skip('should serialize a blob', async () => {
const arrayBuffer = new Uint8Array([1, 2, 0, 4]).buffer;
const blob = new Blob([arrayBuffer], { type: 'image/png' });
const expected = {
rr_type: 'ArrayBuffer',
base64: 'AQIABA==',
};
expect(await serializeArg(blob, window, context)).toStrictEqual({
rr_type: 'Blob',
args: [expected, { type: 'image/png' }],
});
});
});

View File

@@ -11,7 +11,12 @@ import {
IncrementalSource,
CanvasContext,
} from '../../src/types';
import { assertSnapshot, launchPuppeteer, waitForRAF } from '../utils';
import {
assertSnapshot,
launchPuppeteer,
stripBase64,
waitForRAF,
} from '../utils';
import { ICanvas } from 'rrweb-snapshot';
interface ISuite {
@@ -31,7 +36,11 @@ interface IWindow extends Window {
emit: (e: eventWithTime) => undefined;
}
const setup = function (this: ISuite, content: string): ISuite {
const setup = function (
this: ISuite,
content: string,
canvasSample: 'all' | number = 'all',
): ISuite {
const ctx = {} as ISuite;
beforeAll(async () => {
@@ -56,13 +65,16 @@ const setup = function (this: ISuite, content: string): ISuite {
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
await ctx.page.evaluate(() => {
await ctx.page.evaluate((canvasSample) => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
recordCanvas: true,
sampling: {
canvas: canvasSample,
},
emit: ((window as unknown) as IWindow).emit,
});
});
}, canvasSample);
});
afterEach(async () => {
@@ -257,4 +269,52 @@ describe('record webgl', function (this: ISuite) {
assertSnapshot(ctx.events);
expect(ctx.events.length).toEqual(5);
});
describe('recordCanvas FPS', function (this: ISuite) {
jest.setTimeout(10_000);
const maxFPS = 60;
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<body>
<canvas id="canvas"></canvas>
</body>
</html>
`,
maxFPS,
);
it('should record snapshots', async () => {
await ctx.page.evaluate(() => {
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true })!;
// Set the clear color to darkish green.
gl.clearColor(0.0, 0.5, 0.0, 1.0);
// Clear the context with the newly set color. This is
// the function call that actually does the drawing.
gl.clear(gl.COLOR_BUFFER_BIT);
});
await ctx.page.waitForTimeout(200); // give it some time buffer
await ctx.page.evaluate(() => {
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true })!;
// Set the clear color to darkish blue.
gl.clearColor(0.0, 0.0, 0.5, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
});
await ctx.page.waitForTimeout(200);
await waitForRAF(ctx.page);
// should yield a frame for each change at a max of 60fps
assertSnapshot(stripBase64(ctx.events));
});
});
});

View File

@@ -2,11 +2,10 @@
* @jest-environment jsdom
*/
import { deserializeArg } from '../../src/replay/canvas/deserialize-args';
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { deserializeArg } from '../../src/replay/canvas/webgl';
let context: WebGLRenderingContext | WebGL2RenderingContext;
describe('deserializeArg', () => {
beforeEach(() => {
@@ -14,7 +13,7 @@ describe('deserializeArg', () => {
});
it('should deserialize Float32Array values', async () => {
expect(
deserializeArg(
await deserializeArg(
new Map(),
context,
)({
@@ -26,7 +25,7 @@ describe('deserializeArg', () => {
it('should deserialize Float64Array values', async () => {
expect(
deserializeArg(
await deserializeArg(
new Map(),
context,
)({
@@ -39,7 +38,7 @@ describe('deserializeArg', () => {
it('should deserialize ArrayBuffer values', async () => {
const contents = [1, 2, 0, 4];
expect(
deserializeArg(
await deserializeArg(
new Map(),
context,
)({
@@ -51,7 +50,7 @@ describe('deserializeArg', () => {
it('should deserialize DataView values', async () => {
expect(
deserializeArg(
await deserializeArg(
new Map(),
context,
)({
@@ -70,7 +69,7 @@ describe('deserializeArg', () => {
it('should leave arrays intact', async () => {
const array = [1, 2, 3, 4];
expect(deserializeArg(new Map(), context)(array)).toEqual(array);
expect(await deserializeArg(new Map(), context)(array)).toEqual(array);
});
it('should deserialize complex objects', async () => {
@@ -89,22 +88,20 @@ describe('deserializeArg', () => {
5,
6,
];
expect(deserializeArg(new Map(), context)(serializedArg)).toStrictEqual([
new DataView(new ArrayBuffer(16), 0, 16),
5,
6,
]);
expect(
await deserializeArg(new Map(), context)(serializedArg),
).toStrictEqual([new DataView(new ArrayBuffer(16), 0, 16), 5, 6]);
});
it('should leave null as-is', async () => {
expect(deserializeArg(new Map(), context)(null)).toStrictEqual(null);
expect(await deserializeArg(new Map(), context)(null)).toStrictEqual(null);
});
it('should support HTMLImageElements', async () => {
const image = new Image();
image.src = 'http://example.com/image.png';
expect(
deserializeArg(
await deserializeArg(
new Map(),
context,
)({
@@ -121,7 +118,7 @@ describe('deserializeArg', () => {
imageMap.set(image.src, image);
expect(
deserializeArg(
await deserializeArg(
imageMap,
context,
)({
@@ -130,4 +127,77 @@ describe('deserializeArg', () => {
}),
).toBe(image);
});
it('should support blobs', async () => {
const arrayBuffer = new Uint8Array([1, 2, 0, 4]).buffer;
const expected = new Blob([arrayBuffer], { type: 'image/png' });
const deserialized = await deserializeArg(
new Map(),
context,
)({
rr_type: 'Blob',
data: [
{
rr_type: 'ArrayBuffer',
base64: 'AQIABA==',
},
],
type: 'image/png',
});
// `expect(blob).toEqual(otherBlob)` doesn't really do anything yet
// jest hasn't implemented a propper way to compare blobs
// more info: https://github.com/facebook/jest/issues/7372
// because JSDOM doesn't support most functions needed for comparison:
// more info: https://github.com/jsdom/jsdom/issues/2555
expect(deserialized).toEqual(expected);
// thats why we test size of the blob as well
expect(deserialized.size).toEqual(expected.size);
});
describe('isUnchanged', () => {
it('should set isUnchanged:true when non of the args are changed', async () => {
const status = {
isUnchanged: true,
};
await deserializeArg(new Map(), context, status)(true);
expect(status.isUnchanged).toBeTruthy();
});
it('should set isUnchanged: false when args are deserialzed', async () => {
const status = {
isUnchanged: true,
};
await deserializeArg(
new Map(),
context,
status,
)({
rr_type: 'Float64Array',
args: [[-1, -1, 3, -1, -1, 3]],
});
expect(status.isUnchanged).toBeFalsy();
});
it('should set isUnchanged: false when nested args are deserialzed', async () => {
const status = {
isUnchanged: true,
};
await deserializeArg(
new Map(),
context,
status,
)([
{
rr_type: 'Float64Array',
args: [[-1, -1, 3, -1, -1, 3]],
},
]);
expect(status.isUnchanged).toBeFalsy();
});
});
});

View File

@@ -8,7 +8,7 @@ import { Replayer } from '../../src/replay';
import {} from '../../src/types';
import {
CanvasContext,
SerializedWebGlArg,
CanvasArg,
IncrementalSource,
EventType,
eventWithTime,
@@ -16,9 +16,7 @@ import {
let replayer: Replayer;
const canvasMutationEventWithArgs = (
args: SerializedWebGlArg[],
): eventWithTime => {
const canvasMutationEventWithArgs = (args: CanvasArg[]): eventWithTime => {
return {
timestamp: 100,
type: EventType.IncrementalSnapshot,
@@ -67,11 +65,11 @@ describe('preloadAllImages', () => {
);
});
it('should preload nested image', () => {
it('should preload nested image', async () => {
replayer.service.state.context.events = [
canvasMutationEventWithArgs([
{
rr_type: 'something',
rr_type: 'Array',
args: [
{
rr_type: 'HTMLImageElement',
@@ -82,7 +80,7 @@ describe('preloadAllImages', () => {
]),
];
(replayer as any).preloadAllImages();
await (replayer as any).preloadAllImages();
const expectedImage = new Image();
expectedImage.src = 'http://example.com';

View File

@@ -5,8 +5,9 @@
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import webglMutation, { variableListFor } from '../../src/replay/canvas/webgl';
import webglMutation from '../../src/replay/canvas/webgl';
import { CanvasContext } from '../../src/types';
import { variableListFor } from '../../src/replay/canvas/deserialize-args';
let canvas: HTMLCanvasElement;
describe('webglMutation', () => {
@@ -30,7 +31,7 @@ describe('webglMutation', () => {
expect(variableListFor(context, 'WebGLShader')).toHaveLength(0);
webglMutation({
await webglMutation({
mutation: {
property: 'createShader',
args: [35633],

View File

@@ -220,6 +220,39 @@ export async function assertDomSnapshot(
expect(stringifyDomSnapshot(data)).toMatchSnapshot();
}
export function stripBase64(events: eventWithTime[]) {
const base64Strings: string[] = [];
function walk<T>(obj: T): T {
if (!obj || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return (obj.map((e) => walk(e)) as unknown) as T;
const newObj: Partial<T> = {};
for (let prop in obj) {
const value = obj[prop];
if (prop === 'base64' && typeof value === 'string') {
let index = base64Strings.indexOf(value);
if (index === -1) {
index = base64Strings.push(value) - 1;
}
(newObj as any)[prop] = `base64-${index}`;
} else {
(newObj as any)[prop] = walk(value);
}
}
return newObj as T;
}
return events.map((event) => {
if (
event.type === EventType.IncrementalSnapshot &&
event.data.source === IncrementalSource.CanvasMutation
) {
const newData = walk(event.data);
return { ...event, data: newData };
}
return event;
});
}
const now = Date.now();
export const sampleEvents: eventWithTime[] = [
{