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
2022-04-18 07:24:51 +02:00
committed by GitHub
parent 93fec1f3e7
commit e238462f30
43 changed files with 1184 additions and 335 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
}),

View File

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

View File

@@ -221,6 +221,7 @@ function record<T = eventWithTime>(
win: window,
blockClass,
mirror,
sampling: sampling.canvas,
});
const shadowDomManager = new ShadowDomManager({

View File

@@ -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<unknown>
) {
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,

View File

@@ -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<number, boolean> = 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,

View File

@@ -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<string, any[]>;
const webGLVarMap: Map<
WebGLRenderingContext | WebGL2RenderingContext,
GLVarMap
> = new Map();
export function variableListFor(
ctx: WebGLRenderingContext | WebGL2RenderingContext,
ctor: string,
) {
let contextMap = webGLVarMap.get(ctx);
type CanvasVarMap = Map<string, any[]>;
const canvasVarMap: Map<RenderingContext, CanvasVarMap> = 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<any>,
win: IWindow,
ctx: WebGLRenderingContext | WebGL2RenderingContext,
ctx: RenderingContext,
) => {
return [...args].map((arg) => serializeArg(arg, win, ctx));
};

View File

@@ -0,0 +1,77 @@
import { encode } from 'base64-arraybuffer';
import {
ImageBitmapDataURLWorkerParams,
ImageBitmapDataURLWorkerResponse,
} from '../../types';
const lastBlobMap: Map<number, string> = new Map();
const transparentBlobMap: Map<string, string> = new Map();
export interface ImageBitmapDataURLRequestWorker {
postMessage: (
message: ImageBitmapDataURLWorkerParams,
transfer?: [ImageBitmap],
) => void;
onmessage: (message: MessageEvent<ImageBitmapDataURLWorkerResponse>) => void;
}
interface ImageBitmapDataURLResponseWorker {
onmessage:
| null
| ((message: MessageEvent<ImageBitmapDataURLWorkerParams>) => void);
postMessage(e: ImageBitmapDataURLWorkerResponse): void;
}
async function getTransparentBlobFor(
width: number,
height: number,
): Promise<string> {
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);
};

View File

@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"lib": ["webworker"]
},
"exclude": ["workers.d.ts"]
}

View File

@@ -0,0 +1,4 @@
declare module 'web-worker:*' {
const WorkerFactory: new () => Worker;
export default WorkerFactory;
}

View File

@@ -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<void> {
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);

View File

@@ -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<string, any[]>;
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<any> {
return async (arg: CanvasArg): Promise<any> => {
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;
};
}

View File

@@ -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<Replayer['applyIncremental']>[0];
mutation: canvasMutationData;
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
canvasEventMap: Replayer['canvasEventMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void {
}): Promise<void> {
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);
}

View File

@@ -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<string, any[]>;
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<void> {
try {
const ctx = getContext(target, type);
if (!ctx) return;
@@ -137,7 +78,9 @@ export default function webglMutation({
mutation.property as Exclude<keyof typeof ctx, 'canvas'>
] 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);

View File

@@ -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<eventWithTime | string, HTMLImageElement> = new Map();
private canvasEventMap: Map<eventWithTime, canvasMutationParam> = 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<void[]> {
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<void>[] = [];
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),
});

View File

@@ -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<TOptions = unknown> = {
@@ -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<CanvasArg>;
type?: string;
}
| {
rr_type: string;
src: string; // url of image
}
| {
rr_type: string;
args: SerializedWebGlArg[];
args: Array<CanvasArg>;
}
| {
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;

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[] = [
{

View File

@@ -18,13 +18,15 @@ export declare class CanvasManager {
lock(): void;
unlock(): void;
constructor(options: {
recordCanvas: boolean | number;
recordCanvas: boolean;
mutationCb: canvasMutationCallback;
win: IWindow;
blockClass: blockClass;
mirror: Mirror;
sampling?: 'all' | number;
});
private processMutation;
private initCanvasFPSObserver;
private initCanvasMutationObserver;
private startPendingCanvasMutationFlusher;
private startRAFTimestamping;

View File

@@ -1,6 +1,6 @@
import { IWindow, SerializedWebGlArg } from '../../../types';
export declare function variableListFor(ctx: WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[];
export declare const saveWebGLVar: (value: any, win: IWindow, ctx: WebGL2RenderingContext | WebGLRenderingContext) => number | void;
export declare function serializeArg(value: any, win: IWindow, ctx: WebGL2RenderingContext | WebGLRenderingContext): SerializedWebGlArg;
export declare const serializeArgs: (args: Array<any>, win: IWindow, ctx: WebGLRenderingContext | WebGL2RenderingContext) => SerializedWebGlArg[];
import { IWindow, CanvasArg } from '../../../types';
export declare function variableListFor(ctx: RenderingContext, ctor: string): any[];
export declare const saveWebGLVar: (value: any, win: IWindow, ctx: RenderingContext) => number | void;
export declare function serializeArg(value: any, win: IWindow, ctx: RenderingContext): CanvasArg;
export declare const serializeArgs: (args: Array<any>, win: IWindow, ctx: RenderingContext) => CanvasArg[];
export declare const isInstanceOfWebGLObject: (value: any, win: IWindow) => value is WebGLTexture | WebGLShader | WebGLBuffer | WebGLVertexArrayObject | WebGLProgram | WebGLActiveInfo | WebGLUniformLocation | WebGLFramebuffer | WebGLRenderbuffer | WebGLShaderPrecisionFormat;

View File

@@ -0,0 +1,5 @@
import { ImageBitmapDataURLWorkerParams, ImageBitmapDataURLWorkerResponse } from '../../types';
export interface ImageBitmapDataURLRequestWorker {
postMessage: (message: ImageBitmapDataURLWorkerParams, transfer?: [ImageBitmap]) => void;
onmessage: (message: MessageEvent<ImageBitmapDataURLWorkerResponse>) => void;
}

View File

@@ -6,4 +6,4 @@ export default function canvasMutation({ event, mutation, target, imageMap, erro
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void;
}): Promise<void>;

View File

@@ -0,0 +1,7 @@
import type { Replayer } from '../';
import { CanvasArg, SerializedCanvasArg } from '../../types';
export declare function variableListFor(ctx: CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[];
export declare function isSerializedArg(arg: unknown): arg is SerializedCanvasArg;
export declare function deserializeArg(imageMap: Replayer['imageMap'], ctx: CanvasRenderingContext2D | WebGLRenderingContext | WebGL2RenderingContext | null, preload?: {
isUnchanged: boolean;
}): (arg: CanvasArg) => Promise<any>;

View File

@@ -1,9 +1,10 @@
import { Replayer } from '..';
import { canvasMutationData } from '../../types';
export default function canvasMutation({ event, mutation, target, imageMap, errorHandler, }: {
export default function canvasMutation({ event, mutation, target, imageMap, canvasEventMap, errorHandler, }: {
event: Parameters<Replayer['applyIncremental']>[0];
mutation: canvasMutationData;
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
canvasEventMap: Replayer['canvasEventMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void;
}): Promise<void>;

View File

@@ -1,11 +1,9 @@
import { Replayer } from '../';
import { CanvasContext, canvasMutationCommand, SerializedWebGlArg } from '../../types';
export declare function variableListFor(ctx: WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[];
export declare function deserializeArg(imageMap: Replayer['imageMap'], ctx: WebGLRenderingContext | WebGL2RenderingContext): (arg: SerializedWebGlArg) => any;
import type { Replayer } from '../';
import { CanvasContext, canvasMutationCommand } from '../../types';
export default function webglMutation({ mutation, target, type, imageMap, errorHandler, }: {
mutation: canvasMutationCommand;
target: HTMLCanvasElement;
type: CanvasContext;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void;
}): Promise<void>;

View File

@@ -22,6 +22,7 @@ export declare class Replayer {
private virtualStyleRulesMap;
private cache;
private imageMap;
private canvasEventMap;
private mirror;
private firstFullSnapshot;
private newDocumentQueue;
@@ -56,6 +57,7 @@ export declare class Replayer {
private getImageArgs;
private preloadAllImages;
private preloadImages;
private deserializeAndPreloadCanvasEvents;
private applyIncremental;
private applyMutation;
private applyScroll;

View File

@@ -124,6 +124,7 @@ export declare type SamplingStrategy = Partial<{
scroll: number;
media: number;
input: 'all' | 'last';
canvas: 'all' | number;
}>;
export declare type RecordPlugin<TOptions = unknown> = {
name: string;
@@ -291,19 +292,24 @@ export declare enum CanvasContext {
WebGL = 1,
WebGL2 = 2
}
export declare type SerializedWebGlArg = {
export declare type SerializedCanvasArg = {
rr_type: 'ArrayBuffer';
base64: string;
} | {
rr_type: 'Blob';
data: Array<CanvasArg>;
type?: string;
} | {
rr_type: string;
src: string;
} | {
rr_type: string;
args: SerializedWebGlArg[];
args: Array<CanvasArg>;
} | {
rr_type: string;
index: number;
} | string | number | boolean | null | SerializedWebGlArg[];
};
export declare type CanvasArg = SerializedCanvasArg | string | number | boolean | null | CanvasArg[];
declare type mouseInteractionParam = {
type: MouseInteractions;
id: number;
@@ -361,6 +367,21 @@ export declare type canvasMutationWithType = {
} & canvasMutationCommand;
export declare type canvasMutationCallback = (p: canvasMutationParam) => void;
export declare type canvasManagerMutationCallback = (target: HTMLCanvasElement, p: canvasMutationWithType) => void;
export declare type ImageBitmapDataURLWorkerParams = {
id: number;
bitmap: ImageBitmap;
width: number;
height: number;
};
export declare type ImageBitmapDataURLWorkerResponse = {
id: number;
} | {
id: number;
type: string;
base64: string;
width: number;
height: number;
};
export declare type fontParam = {
family: string;
fontSource: string;