Skip to content

Commit acb995b

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 9b0afb7 commit acb995b

File tree

5 files changed

+153
-59
lines changed

5 files changed

+153
-59
lines changed

packages/rrweb-snapshot/src/snapshot.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,7 @@ function serializeElementNode(
613613
} = options;
614614
let needBlock = _isBlockedElement(n, blockClass, blockSelector);
615615
const needMask = _isBlockedElement(n, maskTextClass, null);
616-
const tagName = getValidTagName(n);
616+
let tagName = getValidTagName(n);
617617
let attributes: attributes = {};
618618
const len = n.attributes.length;
619619
for (let i = 0; i < len; i++) {
@@ -838,6 +838,19 @@ function serializeElementNode(
838838
// In case old browsers don't support customElements
839839
}
840840

841+
if (inlineImages && tagName === 'video') {
842+
const video = n as HTMLVideoElement;
843+
if (video.src === '' || video.src.indexOf('blob:') !== -1) {
844+
const { width, height } = n.getBoundingClientRect();
845+
attributes = {
846+
rr_width: `${width}px`,
847+
rr_height: `${height}px`,
848+
rr_inlined_video: true,
849+
};
850+
tagName = 'canvas';
851+
}
852+
}
853+
841854
return {
842855
type: NodeType.Element,
843856
tagName,
@@ -1362,7 +1375,7 @@ function snapshot(
13621375
}
13631376
: maskAllInputs;
13641377
const slimDOMOptions: SlimDOMOptions =
1365-
slimDOM === true || (slimDOM as unknown) === 'all'
1378+
slimDOM || (slimDOM as unknown) === 'all'
13661379
? // if true: set of sensible options that should not throw away any information
13671380
{
13681381
script: true,
@@ -1376,7 +1389,7 @@ function snapshot(
13761389
headMetaAuthorship: true,
13771390
headMetaVerification: true,
13781391
}
1379-
: slimDOM === false
1392+
: !slimDOM
13801393
? {}
13811394
: slimDOM;
13821395
return serializeNodeWithId(n, {

packages/rrweb/src/record/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ function record<T = eventWithTime>(
8585
hooks,
8686
packFn,
8787
sampling = {},
88-
dataURLOptions = {},
8988
mousemoveWait,
9089
recordDOM = true,
9190
recordCanvas = false,
@@ -103,6 +102,10 @@ function record<T = eventWithTime>(
103102
errorHandler,
104103
logger,
105104
} = options;
105+
const dataURLOptions = {
106+
...options.dataURLOptions,
107+
...options.sampling?.canvas?.dataURLOptions,
108+
};
106109

107110
registerErrorHandler(errorHandler);
108111

@@ -321,14 +324,14 @@ function record<T = eventWithTime>(
321324

322325
canvasManager = new CanvasManager({
323326
recordCanvas,
327+
recordVideos: inlineImages,
324328
mutationCb: wrappedCanvasMutationEmit,
325329
win: window,
326330
blockClass,
327331
blockSelector,
328332
mirror,
329333
sampling: sampling?.canvas?.fps,
330334
dataURLOptions,
331-
resizeQuality: sampling?.canvas?.resizeQuality,
332335
resizeFactor: sampling?.canvas?.resizeFactor,
333336
maxSnapshotDimension: sampling?.canvas?.maxSnapshotDimension,
334337
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 {

0 commit comments

Comments
 (0)