Skip to content

Commit de7d537

Browse files
committed
support snapshotting video elements (#103)
local webcam video replay https://www.loom.com/share/c0a473d717d94b4f88ce010a440fb474 webcam image getting serialized ![image](https://user-images.githubusercontent.com/1351531/236074015-43475a04-9bfb-4101-ad32-d3bfb0337723.png) --------- Co-authored-by: Vadman97 <[email protected]>
1 parent b4adcc6 commit de7d537

File tree

8 files changed

+157
-62
lines changed

8 files changed

+157
-62
lines changed

packages/rrweb-snapshot/src/snapshot.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ function serializeElementNode(
667667
} = options;
668668
let needBlock = _isBlockedElement(n, blockClass, blockSelector);
669669
const needMask = _isBlockedElement(n, maskTextClass, null);
670-
const tagName = getValidTagName(n);
670+
let tagName = getValidTagName(n);
671671
let attributes: attributes = {};
672672
const len = n.attributes.length;
673673
for (let i = 0; i < len; i++) {
@@ -865,6 +865,19 @@ function serializeElementNode(
865865
// In case old browsers don't support customElements
866866
}
867867

868+
if (inlineImages && tagName === 'video') {
869+
const video = n as HTMLVideoElement;
870+
if (video.src === '' || video.src.indexOf('blob:') !== -1) {
871+
const { width, height } = n.getBoundingClientRect();
872+
attributes = {
873+
rr_width: `${width}px`,
874+
rr_height: `${height}px`,
875+
rr_inlined_video: true,
876+
};
877+
tagName = 'canvas';
878+
}
879+
}
880+
868881
return {
869882
type: NodeType.Element,
870883
tagName,
@@ -1394,7 +1407,7 @@ function snapshot(
13941407
}
13951408
: maskAllInputs;
13961409
const slimDOMOptions: SlimDOMOptions =
1397-
slimDOM === true || (slimDOM as unknown) === 'all'
1410+
slimDOM || (slimDOM as unknown) === 'all'
13981411
? // if true: set of sensible options that should not throw away any information
13991412
{
14001413
script: true,
@@ -1408,7 +1421,7 @@ function snapshot(
14081421
headMetaAuthorship: true,
14091422
headMetaVerification: true,
14101423
}
1411-
: slimDOM === false
1424+
: !slimDOM
14121425
? {}
14131426
: slimDOM;
14141427
return serializeNodeWithId(n, {

packages/rrweb/src/record/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ function record<T = eventWithTime>(
7777
hooks,
7878
packFn,
7979
sampling = {},
80-
dataURLOptions = {},
8180
mousemoveWait,
8281
recordDOM = true,
8382
recordCanvas = false,
@@ -95,6 +94,10 @@ function record<T = eventWithTime>(
9594
errorHandler,
9695
logger,
9796
} = options;
97+
const dataURLOptions = {
98+
...options.dataURLOptions,
99+
...options.sampling?.canvas?.dataURLOptions,
100+
};
98101

99102
registerErrorHandler(errorHandler);
100103

@@ -313,14 +316,14 @@ function record<T = eventWithTime>(
313316

314317
canvasManager = new CanvasManager({
315318
recordCanvas,
319+
recordVideos: inlineImages,
316320
mutationCb: wrappedCanvasMutationEmit,
317321
win: window,
318322
blockClass,
319323
blockSelector,
320324
mirror,
321325
sampling: sampling?.canvas?.fps,
322326
dataURLOptions,
323-
resizeQuality: sampling?.canvas?.resizeQuality,
324327
resizeFactor: sampling?.canvas?.resizeFactor,
325328
maxSnapshotDimension: sampling?.canvas?.maxSnapshotDimension,
326329
logger: logger,

packages/rrweb/src/record/observers/canvas/canvas-manager.ts

Lines changed: 113 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@ export class CanvasManager {
6565

6666
constructor(options: {
6767
recordCanvas: boolean;
68+
recordVideos: boolean;
6869
mutationCb: canvasMutationCallback;
6970
win: IWindow;
7071
blockClass: blockClass;
7172
blockSelector: string | null;
7273
mirror: Mirror;
7374
sampling?: 'all' | number;
7475
dataURLOptions: DataURLOptions;
75-
resizeQuality?: 'pixelated' | 'low' | 'medium' | 'high';
7676
resizeFactor?: number;
7777
maxSnapshotDimension?: number;
7878
logger?: {
@@ -86,6 +86,7 @@ export class CanvasManager {
8686
blockClass,
8787
blockSelector,
8888
recordCanvas,
89+
recordVideos,
8990
dataURLOptions,
9091
} = options;
9192
this.mutationCb = options.mutationCb;
@@ -96,29 +97,29 @@ export class CanvasManager {
9697
this.initCanvasMutationObserver(win, blockClass, blockSelector);
9798
if (recordCanvas && typeof sampling === 'number')
9899
this.initCanvasFPSObserver(
100+
recordVideos,
99101
sampling,
100102
win,
101103
blockClass,
102104
blockSelector,
103105
{
104106
dataURLOptions,
105107
},
106-
options.resizeQuality,
107108
options.resizeFactor,
108109
options.maxSnapshotDimension,
109110
);
110111
}
111112

112113
private debug(
113-
canvas?: HTMLCanvasElement,
114+
element: HTMLCanvasElement | HTMLVideoElement,
114115
...args: Parameters<typeof console.log>
115116
) {
116117
if (!this.logger) return;
117-
let prefix = '[highlight-canvas]';
118-
if (canvas) {
119-
prefix += ` [ctx:${(canvas as ICanvas).__context}]`;
118+
let prefix = `[highlight-${element.tagName.toLowerCase()}]`;
119+
if (element.tagName.toLowerCase() === 'canvas') {
120+
prefix += ` [ctx:${(element as ICanvas).__context}]`;
120121
}
121-
this.logger.debug(prefix, canvas, ...args);
122+
this.logger.debug(prefix, element, ...args);
122123
}
123124

124125
private processMutation: canvasManagerMutationCallback = (
@@ -139,14 +140,14 @@ export class CanvasManager {
139140
};
140141

141142
private initCanvasFPSObserver(
143+
recordVideos: boolean,
142144
fps: number,
143145
win: IWindow,
144146
blockClass: blockClass,
145147
blockSelector: string | null,
146148
options: {
147149
dataURLOptions: DataURLOptions;
148150
},
149-
resizeQuality?: 'pixelated' | 'low' | 'medium' | 'high',
150151
resizeFactor?: number,
151152
maxSnapshotDimension?: number,
152153
) {
@@ -165,14 +166,14 @@ export class CanvasManager {
165166

166167
if (!('base64' in e.data)) return;
167168

168-
const { base64, type, canvasWidth, canvasHeight } = e.data;
169+
const { base64, type, dx, dy, dw, dh } = e.data;
169170
this.mutationCb({
170171
id,
171172
type: CanvasContext['2D'],
172173
commands: [
173174
{
174175
property: 'clearRect', // wipe canvas
175-
args: [0, 0, canvasWidth, canvasHeight],
176+
args: [dx, dy, dw, dh],
176177
},
177178
{
178179
property: 'drawImage', // draws (semi-transparent) image
@@ -187,10 +188,10 @@ export class CanvasManager {
187188
},
188189
],
189190
} as CanvasArg,
190-
0,
191-
0,
192-
canvasWidth,
193-
canvasHeight,
191+
dx,
192+
dy,
193+
dw,
194+
dh,
194195
],
195196
},
196197
],
@@ -212,17 +213,31 @@ export class CanvasManager {
212213
return matchedCanvas;
213214
};
214215

215-
const takeCanvasSnapshots = (timestamp: DOMHighResTimeStamp) => {
216+
const getVideos = (): HTMLVideoElement[] => {
217+
const matchedVideos: HTMLVideoElement[] = [];
218+
if (recordVideos) {
219+
win.document.querySelectorAll('video').forEach((video) => {
220+
if (video.src !== '' && video.src.indexOf('blob:') === -1) return;
221+
if (!isBlocked(video, blockClass, blockSelector, true)) {
222+
matchedVideos.push(video);
223+
}
224+
});
225+
}
226+
return matchedVideos;
227+
};
228+
229+
const takeSnapshots = async (timestamp: DOMHighResTimeStamp) => {
216230
if (
217231
lastSnapshotTime &&
218232
timestamp - lastSnapshotTime < timeBetweenSnapshots
219233
) {
220-
rafId = requestAnimationFrame(takeCanvasSnapshots);
234+
rafId = requestAnimationFrame(takeSnapshots);
221235
return;
222236
}
223237
lastSnapshotTime = timestamp;
224238

225-
getCanvas().forEach(async (canvas: HTMLCanvasElement) => {
239+
const promises: Promise<void>[] = []
240+
promises.push(...getCanvas().map(async (canvas: HTMLCanvasElement) => {
226241
this.debug(canvas, 'starting snapshotting');
227242
const id = this.mirror.getId(canvas);
228243
if (snapshotInProgressMap.get(id)) {
@@ -273,43 +288,107 @@ export class CanvasManager {
273288
const width = canvas.width * scale;
274289
const height = canvas.height * scale;
275290

276-
window.performance.mark(`canvas-${canvas.id}-snapshot`);
277291
const bitmap = await createImageBitmap(canvas, {
278-
resizeQuality: resizeQuality || 'low',
279292
resizeWidth: width,
280293
resizeHeight: height,
281294
});
282-
this.debug(
283-
canvas,
284-
'took a snapshot in',
285-
window.performance.measure(`canvas-snapshot`),
295+
this.debug(canvas, 'created image bitmap');
296+
worker.postMessage(
297+
{
298+
id,
299+
bitmap,
300+
width,
301+
height,
302+
dx: 0,
303+
dy: 0,
304+
dw: canvas.width,
305+
dh: canvas.height,
306+
dataURLOptions: options.dataURLOptions,
307+
},
308+
[bitmap],
286309
);
287-
window.performance.mark(`canvas-postMessage`);
310+
this.debug(canvas, 'sent message');
311+
} catch (e) {
312+
this.debug(canvas, 'failed to snapshot', e);
313+
} finally {
314+
snapshotInProgressMap.set(id, false);
315+
}
316+
}))
317+
promises.push(...getVideos().map(async (video: HTMLVideoElement) => {
318+
this.debug(video, 'starting video snapshotting');
319+
const id = this.mirror.getId(video);
320+
if (snapshotInProgressMap.get(id)) {
321+
this.debug(video, 'video snapshotting already in progress for', id);
322+
return;
323+
}
324+
snapshotInProgressMap.set(id, true);
325+
try {
326+
const { width: boxWidth, height: boxHeight } =
327+
video.getBoundingClientRect();
328+
const { actualWidth, actualHeight } = {
329+
actualWidth: video.videoWidth,
330+
actualHeight: video.videoHeight,
331+
};
332+
const maxDim = Math.max(actualWidth, actualHeight);
333+
let scale = resizeFactor || 1;
334+
if (maxSnapshotDimension) {
335+
scale = Math.min(scale, maxSnapshotDimension / maxDim);
336+
}
337+
const width = actualWidth * scale;
338+
const height = actualHeight * scale;
339+
340+
const bitmap = await createImageBitmap(video, {
341+
resizeWidth: width,
342+
resizeHeight: height,
343+
});
344+
345+
let outputScale = Math.max(boxWidth, boxHeight) / maxDim;
346+
const outputWidth = actualWidth * outputScale;
347+
const outputHeight = actualHeight * outputScale;
348+
const offsetX = (boxWidth - outputWidth) / 2;
349+
const offsetY = (boxHeight - outputHeight) / 2;
350+
this.debug(video, 'created image bitmap', {
351+
actualWidth,
352+
actualHeight,
353+
boxWidth,
354+
boxHeight,
355+
outputWidth,
356+
outputHeight,
357+
resizeWidth: width,
358+
resizeHeight: height,
359+
scale,
360+
outputScale,
361+
offsetX,
362+
offsetY,
363+
});
364+
288365
worker.postMessage(
289366
{
290367
id,
291368
bitmap,
292369
width,
293370
height,
294-
canvasWidth: canvas.width,
295-
canvasHeight: canvas.height,
371+
dx: offsetX,
372+
dy: offsetY,
373+
dw: outputWidth,
374+
dh: outputHeight,
296375
dataURLOptions: options.dataURLOptions,
297376
},
298377
[bitmap],
299378
);
300-
this.debug(
301-
canvas,
302-
'send message in',
303-
window.performance.measure(`canvas-postMessage`),
304-
);
379+
this.debug(video, 'send message');
380+
} catch (e) {
381+
this.debug(video, 'failed to snapshot', e);
305382
} finally {
306383
snapshotInProgressMap.set(id, false);
307384
}
308-
});
309-
rafId = requestAnimationFrame(takeCanvasSnapshots);
385+
}))
386+
await Promise.all(promises)
387+
388+
rafId = requestAnimationFrame(takeSnapshots);
310389
};
311390

312-
rafId = requestAnimationFrame(takeCanvasSnapshots);
391+
rafId = requestAnimationFrame(takeSnapshots);
313392

314393
this.resetObservers = () => {
315394
canvasContextReset();

packages/rrweb/src/record/workers/image-bitmap-data-url-worker.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,8 @@ const worker: ImageBitmapDataURLResponseWorker = self;
4949
// eslint-disable-next-line @typescript-eslint/no-misused-promises
5050
worker.onmessage = async function (e) {
5151
if ('OffscreenCanvas' in globalThis) {
52-
const {
53-
id,
54-
bitmap,
55-
width,
56-
height,
57-
canvasWidth,
58-
canvasHeight,
59-
dataURLOptions,
60-
} = e.data;
52+
const { id, bitmap, width, height, dx, dy, dw, dh, dataURLOptions } =
53+
e.data;
6154

6255
const transparentBase64 = getTransparentBlobFor(
6356
width,
@@ -89,8 +82,10 @@ worker.onmessage = async function (e) {
8982
base64,
9083
width,
9184
height,
92-
canvasWidth,
93-
canvasHeight,
85+
dx,
86+
dy,
87+
dw,
88+
dh,
9489
});
9590
lastBlobMap.set(id, base64);
9691
} else {

packages/types/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"dev": "vite",
1313
"check-types": "tsc -noEmit",
1414
"lint": "yarn eslint src/**/*.ts",
15-
"typegen": "vite build"
15+
"typegen": "tsc --emitDeclarationOnly -d",
16+
"build": "vite build"
1617
},
1718
"homepage": "https://github.com/rrweb-io/rrweb/tree/main/packages/@rrweb/types#readme",
1819
"bugs": {

0 commit comments

Comments
 (0)