Full overhawl of video & audio playback to make it more complete (#1432)

* Add support for capturing media attributes in rrweb-snapshot

* Add loop to mediaInteractionParam

* Add support for loop in RRMediaElement

* Add support for recording loop attribute on media elements

* Update video playback and fix bugs

* Update cross-origin iframe media attributes and player state
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent c61c906e31
commit f6543be763
28 changed files with 1886 additions and 59 deletions

View File

@@ -0,0 +1,5 @@
---
'rrdom': patch
---
Support `loop` in `RRMediaElement`

View File

@@ -0,0 +1,5 @@
---
'rrweb-snapshot': minor
---
Video and Audio elements now also capture `playbackRate`, `muted`, `loop`, `volume`.

View File

@@ -0,0 +1,5 @@
---
'rrweb': minor
---
Full overhawl of `video` and `audio` element playback. More robust and fixes lots of bugs related to pausing/playing/skipping/muting/playbackRate etc.

View File

@@ -0,0 +1,5 @@
---
'@rrweb/types': patch
---
Add `loop` to `mediaInteractionParam`

View File

@@ -0,0 +1,5 @@
---
'rrweb': patch
---
Record `loop` on `<audio>` & `<video>` elements.

View File

@@ -250,6 +250,8 @@ function diffAfterUpdatingChildren(
oldMediaElement.currentTime = newMediaRRElement.currentTime;
if (newMediaRRElement.playbackRate !== undefined)
oldMediaElement.playbackRate = newMediaRRElement.playbackRate;
if (newMediaRRElement.loop !== undefined)
oldMediaElement.loop = newMediaRRElement.loop;
break;
}
case 'CANVAS': {

View File

@@ -563,6 +563,7 @@ export function BaseRRMediaElementImpl<
public paused?: boolean;
public muted?: boolean;
public playbackRate?: number;
public loop?: boolean;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
attachShadow(_init: ShadowRootInit): IRRElement {
throw new Error(

View File

@@ -280,6 +280,7 @@ describe('diff algorithm for rrdom', () => {
rrMedia.muted = true;
rrMedia.paused = false;
rrMedia.playbackRate = 0.5;
rrMedia.loop = false;
diff(element, rrMedia, replayer);
expect(element.volume).toEqual(0.5);
@@ -287,6 +288,7 @@ describe('diff algorithm for rrdom', () => {
expect(element.muted).toEqual(true);
expect(element.paused).toEqual(false);
expect(element.playbackRate).toEqual(0.5);
expect(element.loop).toEqual(false);
rrMedia.paused = true;
diff(element, rrMedia, replayer);

View File

@@ -1079,6 +1079,7 @@ describe('Basic RRDocument implementation', () => {
expect(node.paused).toBeUndefined();
expect(node.muted).toBeUndefined();
expect(node.playbackRate).toBeUndefined();
expect(node.loop).toBeUndefined();
expect(node.play).toBeDefined();
expect(node.pause).toBeDefined();
expect(node.toString()).toEqual('VIDEO ');

View File

@@ -342,6 +342,17 @@ function buildNode(
break;
default:
}
} else if (
name === 'rr_mediaPlaybackRate' &&
typeof value === 'number'
) {
(node as HTMLMediaElement).playbackRate = value;
} else if (name === 'rr_mediaMuted' && typeof value === 'boolean') {
(node as HTMLMediaElement).muted = value;
} else if (name === 'rr_mediaLoop' && typeof value === 'boolean') {
(node as HTMLMediaElement).loop = value;
} else if (name === 'rr_mediaVolume' && typeof value === 'number') {
(node as HTMLMediaElement).volume = value;
}
}

View File

@@ -12,6 +12,7 @@ import {
ICanvas,
elementNode,
serializedElementNodeWithId,
type mediaAttributes,
} from './types';
import {
Mirror,
@@ -761,10 +762,15 @@ function serializeElementNode(
}
// media elements
if (tagName === 'audio' || tagName === 'video') {
attributes.rr_mediaState = (n as HTMLMediaElement).paused
const mediaAttributes = attributes as mediaAttributes;
mediaAttributes.rr_mediaState = (n as HTMLMediaElement).paused
? 'paused'
: 'played';
attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
mediaAttributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime;
mediaAttributes.rr_mediaPlaybackRate = (n as HTMLMediaElement).playbackRate;
mediaAttributes.rr_mediaMuted = (n as HTMLMediaElement).muted;
mediaAttributes.rr_mediaLoop = (n as HTMLMediaElement).loop;
mediaAttributes.rr_mediaVolume = (n as HTMLMediaElement).volume;
}
// Scroll
if (!newlyAddedElement) {

View File

@@ -82,6 +82,27 @@ export type tagMap = {
[key: string]: string;
};
export type mediaAttributes = {
rr_mediaState: 'played' | 'paused';
rr_mediaCurrentTime: number;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaPlaybackRate?: number;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaMuted?: boolean;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaLoop?: boolean;
/**
* for backwards compatibility this is optional but should always be set
*/
rr_mediaVolume?: number;
};
// @deprecated
export interface INode extends Node {
__sn: serializedNodeWithId;

View File

@@ -1042,7 +1042,7 @@ function initMediaInteractionObserver({
) {
return;
}
const { currentTime, volume, muted, playbackRate } =
const { currentTime, volume, muted, playbackRate, loop } =
target as HTMLMediaElement;
mediaInteractionCb({
type,
@@ -1051,6 +1051,7 @@ function initMediaInteractionObserver({
volume,
muted,
playbackRate,
loop,
});
}),
sampling.media || 500,

View File

@@ -47,7 +47,6 @@ import {
ReplayerEvents,
Handler,
Emitter,
MediaInteractions,
metaEvent,
mutationData,
scrollData,
@@ -81,6 +80,7 @@ import getInjectStyleRules from './styles/inject-style';
import './styles/style.css';
import canvasMutation from './canvas';
import { deserializeArg } from './canvas/deserialize-args';
import { MediaManager } from './media';
const SKIP_TIME_INTERVAL = 5 * 1000;
@@ -142,6 +142,9 @@ export class Replayer {
// Used to track StyleSheetObjects adopted on multiple document hosts.
private styleMirror: StyleSheetMirror = new StyleSheetMirror();
// Used to track video & audio elements, and keep them in sync with general playback.
private mediaManager: MediaManager;
private firstFullSnapshot: eventWithTime | true | null = null;
private newDocumentQueue: addedNodeMutation[] = [];
@@ -324,6 +327,7 @@ export class Replayer {
this.firstFullSnapshot = null;
this.mirror.reset();
this.styleMirror.reset();
this.mediaManager.reset();
});
const timer = new Timer([], {
@@ -366,6 +370,13 @@ export class Replayer {
speed: state,
});
});
this.mediaManager = new MediaManager({
warn: this.warn.bind(this),
service: this.service,
speedService: this.speedService,
emitter: this.emitter,
getCurrentTime: this.getCurrentTime.bind(this),
});
// rebuild first full snapshot as the poster of the player
// maybe we can cache it for performance optimization
@@ -464,10 +475,16 @@ export class Replayer {
};
}
/**
* Get the actual time offset the player is at now compared to the first event.
*/
public getCurrentTime(): number {
return this.timer.timeOffset + this.getTimeOffset();
}
/**
* Get the time offset the player is at now compared to the first event, but without regard for the timer.
*/
public getTimeOffset(): number {
const { baselineTime, events } = this.service.state.context;
return baselineTime - events[0].timestamp;
@@ -527,6 +544,9 @@ export class Replayer {
*/
public destroy() {
this.pause();
this.mirror.reset();
this.styleMirror.reset();
this.mediaManager.reset();
this.config.root.removeChild(this.wrapper);
this.emitter.emit(ReplayerEvents.Destroy);
}
@@ -667,9 +687,10 @@ export class Replayer {
// Timer (requestAnimationFrame) can be faster than setTimeout(..., 1)
this.firstFullSnapshot = true;
}
this.mediaManager.reset();
this.styleMirror.reset();
this.rebuildFullSnapshot(event, isSync);
this.iframe.contentWindow?.scrollTo(event.data.initialOffset);
this.styleMirror.reset();
};
break;
case EventType.IncrementalSnapshot:
@@ -778,6 +799,14 @@ export class Replayer {
const collected: AppendedIframe[] = [];
const afterAppend = (builtNode: Node, id: number) => {
this.collectIframeAndAttachDocument(collected, builtNode);
if (this.mediaManager.isSupportedMediaElement(builtNode)) {
const { events } = this.service.state.context;
this.mediaManager.addMediaElements(
builtNode,
event.timestamp - events[0].timestamp,
this.mirror,
);
}
for (const plugin of this.config.plugins || []) {
if (plugin.onBuild)
plugin.onBuild(builtNode, {
@@ -1261,35 +1290,14 @@ export class Replayer {
return this.debugNodeNotFound(d, d.id);
}
const mediaEl = target as HTMLMediaElement | RRMediaElement;
try {
if (d.currentTime !== undefined) {
mediaEl.currentTime = d.currentTime;
}
if (d.volume !== undefined) {
mediaEl.volume = d.volume;
}
if (d.muted !== undefined) {
mediaEl.muted = d.muted;
}
if (d.type === MediaInteractions.Pause) {
mediaEl.pause();
}
if (d.type === MediaInteractions.Play) {
// remove listener for 'canplay' event because play() is async and returns a promise
// i.e. media will evntualy start to play when data is loaded
// 'canplay' event fires even when currentTime attribute changes which may lead to
// unexpeted behavior
void mediaEl.play();
}
if (d.type === MediaInteractions.RateChange) {
mediaEl.playbackRate = d.playbackRate;
}
} catch (error) {
this.warn(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions
`Failed to replay media interactions: ${error.message || error}`,
);
}
const { events } = this.service.state.context;
this.mediaManager.mediaMutation({
target: mediaEl,
timeOffset: e.timestamp - events[0].timestamp,
mutation: d,
});
break;
}
case IncrementalSource.StyleSheetRule:
@@ -1366,6 +1374,11 @@ export class Replayer {
}
}
/**
* Apply the mutation to the virtual dom or the real dom.
* @param d - The mutation data.
* @param isSync - Whether the mutation should be applied synchronously (while fast-forwarding).
*/
private applyMutation(d: mutationData, isSync: boolean) {
// Only apply virtual dom optimization if the fast-forward process has node mutation. Because the cost of creating a virtual dom tree and executing the diff algorithm is usually higher than directly applying other kind of events.
if (this.config.useVirtualDom && !this.usingVirtualDom && isSync) {

View File

@@ -0,0 +1,294 @@
import { Emitter, MediaInteractions, ReplayerEvents } from '@rrweb/types';
import type { RRMediaElement } from 'rrdom/es';
import type { createPlayerService, createSpeedService } from '../machine';
import type { Mirror, mediaAttributes } from 'rrweb-snapshot';
import type { mediaInteractionData } from '@rrweb/types';
type MediaState = {
isPlaying: boolean;
currentTimeAtLastInteraction: number;
lastInteractionTimeOffset: number;
playbackRate: number;
loop: boolean;
volume: number;
muted: boolean;
};
export class MediaManager {
private mediaMap: Map<HTMLMediaElement | RRMediaElement, MediaState> =
new Map();
private warn: (...args: Parameters<typeof console.warn>) => void;
private service: ReturnType<typeof createPlayerService>;
private speedService: ReturnType<typeof createSpeedService>;
private emitter: Emitter;
private getCurrentTime: () => number;
private metadataCallbackMap: WeakMap<
HTMLMediaElement | RRMediaElement,
() => void
> = new Map();
constructor(options: {
warn: (...args: Parameters<typeof console.warn>) => void;
service: ReturnType<typeof createPlayerService>;
speedService: ReturnType<typeof createSpeedService>;
getCurrentTime: () => number;
emitter: Emitter;
}) {
this.warn = options.warn;
this.service = options.service;
this.speedService = options.speedService;
this.emitter = options.emitter;
this.getCurrentTime = options.getCurrentTime;
this.emitter.on(ReplayerEvents.Start, this.start.bind(this));
this.emitter.on(ReplayerEvents.SkipStart, this.start.bind(this));
this.emitter.on(ReplayerEvents.Pause, this.pause.bind(this));
this.emitter.on(ReplayerEvents.Finish, this.pause.bind(this));
this.speedService.subscribe(() => {
this.syncAllMediaElements();
});
}
private syncAllMediaElements(options = { pause: false }) {
this.mediaMap.forEach((mediaState, target) => {
this.syncTargetWithState(target);
if (options.pause) {
target.pause();
}
});
}
private start() {
this.syncAllMediaElements();
}
private pause() {
this.syncAllMediaElements({ pause: true });
}
private seekTo({
time,
target,
mediaState,
}: {
time: number;
target: HTMLMediaElement | RRMediaElement;
mediaState: MediaState;
}) {
if (mediaState.isPlaying) {
const differenceBetweenCurrentTimeAndMediaMutationTimestamp =
time - mediaState.lastInteractionTimeOffset;
const mediaPlaybackOffset =
(differenceBetweenCurrentTimeAndMediaMutationTimestamp / 1000) *
mediaState.playbackRate;
const duration = 'duration' in target && target.duration;
// Video hasn't loaded yet, wait for metadata
if (Number.isNaN(duration)) {
this.waitForMetadata(target);
return;
}
let seekToTime =
mediaState.currentTimeAtLastInteraction + mediaPlaybackOffset;
if (
target.loop &&
// RRMediaElement doesn't have a duration property
duration !== false
) {
seekToTime = seekToTime % duration;
}
target.currentTime = seekToTime;
} else {
target.pause();
target.currentTime = mediaState.currentTimeAtLastInteraction;
}
}
private waitForMetadata(target: HTMLMediaElement | RRMediaElement) {
if (this.metadataCallbackMap.has(target)) return;
if (!('addEventListener' in target)) return;
const onLoadedMetadata = () => {
this.metadataCallbackMap.delete(target);
const mediaState = this.mediaMap.get(target);
if (!mediaState) return;
this.seekTo({
time: this.getCurrentTime(),
target,
mediaState,
});
};
this.metadataCallbackMap.set(target, onLoadedMetadata);
target.addEventListener('loadedmetadata', onLoadedMetadata, {
once: true,
});
}
private getMediaStateFromMutation({
target,
timeOffset,
mutation,
}: {
target: HTMLMediaElement | RRMediaElement;
timeOffset: number;
mutation: mediaInteractionData;
}): MediaState {
const lastState = this.mediaMap.get(target);
const { type, playbackRate, currentTime, muted, volume, loop } = mutation;
const isPlaying =
type === MediaInteractions.Play ||
(type !== MediaInteractions.Pause &&
(lastState?.isPlaying || target.getAttribute('autoplay') !== null));
const mediaState: MediaState = {
isPlaying,
currentTimeAtLastInteraction:
currentTime ?? lastState?.currentTimeAtLastInteraction ?? 0,
lastInteractionTimeOffset: timeOffset,
playbackRate: playbackRate ?? lastState?.playbackRate ?? 1,
volume: volume ?? lastState?.volume ?? 1,
muted: muted ?? lastState?.muted ?? target.getAttribute('muted') === null,
loop: loop ?? lastState?.loop ?? target.getAttribute('loop') === null,
};
return mediaState;
}
private syncTargetWithState(target: HTMLMediaElement | RRMediaElement) {
const mediaState = this.mediaMap.get(target);
if (!mediaState) return;
const { muted, loop, volume, isPlaying } = mediaState;
const playerIsPaused = this.service.state.matches('paused');
const playbackRate =
mediaState.playbackRate * this.speedService.state.context.timer.speed;
try {
this.seekTo({
time: this.getCurrentTime(),
target,
mediaState,
});
if (target.volume !== volume) {
target.volume = volume;
}
target.muted = muted;
target.loop = loop;
if (target.playbackRate !== playbackRate) {
// Avoid setting playbackRate when it's already the same
// Safari drops frames whenever playbackRate is set, even if it's the same
target.playbackRate = playbackRate;
}
if (isPlaying && !playerIsPaused) {
// remove listener for 'canplay' event because play() is async and returns a promise
// i.e. media will eventually start to play when data is loaded
// 'canplay' event fires even when currentTime attribute changes which may lead to
// unexpected behavior
void target.play();
} else {
target.pause();
}
} catch (error) {
this.warn(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions
`Failed to replay media interactions: ${error.message || error}`,
);
}
}
public addMediaElements(node: Node, timeOffset: number, mirror: Mirror) {
if (!['AUDIO', 'VIDEO'].includes(node.nodeName)) return;
const target = node as HTMLMediaElement;
const serializedNode = mirror.getMeta(target);
if (!serializedNode || !('attributes' in serializedNode)) return;
const playerIsPaused = this.service.state.matches('paused');
const mediaAttributes = serializedNode.attributes as
| mediaAttributes
| Record<string, never>;
let isPlaying = false;
if (mediaAttributes.rr_mediaState) {
isPlaying = mediaAttributes.rr_mediaState === 'played';
} else {
isPlaying = target.getAttribute('autoplay') !== null;
}
if (isPlaying && playerIsPaused) target.pause();
let playbackRate = 1;
if (typeof mediaAttributes.rr_mediaPlaybackRate === 'number') {
playbackRate = mediaAttributes.rr_mediaPlaybackRate;
}
let muted = false;
if (typeof mediaAttributes.rr_mediaMuted === 'boolean') {
muted = mediaAttributes.rr_mediaMuted;
} else {
muted = target.getAttribute('muted') !== null;
}
let loop = false;
if (typeof mediaAttributes.rr_mediaLoop === 'boolean') {
loop = mediaAttributes.rr_mediaLoop;
} else {
loop = target.getAttribute('loop') !== null;
}
let volume = 1;
if (typeof mediaAttributes.rr_mediaVolume === 'number') {
volume = mediaAttributes.rr_mediaVolume;
}
let currentTimeAtLastInteraction = 0;
if (typeof mediaAttributes.rr_mediaCurrentTime === 'number') {
currentTimeAtLastInteraction = mediaAttributes.rr_mediaCurrentTime;
}
this.mediaMap.set(target, {
isPlaying,
currentTimeAtLastInteraction,
lastInteractionTimeOffset: timeOffset,
playbackRate,
volume,
muted,
loop,
});
this.syncTargetWithState(target);
}
public mediaMutation({
target,
timeOffset,
mutation,
}: {
target: HTMLMediaElement | RRMediaElement;
timeOffset: number;
mutation: mediaInteractionData;
}) {
this.mediaMap.set(
target,
this.getMediaStateFromMutation({
target,
timeOffset,
mutation,
}),
);
this.syncTargetWithState(target);
}
public isSupportedMediaElement(node: Node): node is HTMLMediaElement {
return ['AUDIO', 'VIDEO'].includes(node.nodeName);
}
public reset() {
this.mediaMap.clear();
}
}

View File

@@ -9,6 +9,8 @@ import {
waitForRAF,
generateRecordSnippet,
ISuite,
hideMouseAnimation,
fakeGoto,
} from '../utils';
import type { recordOptions } from '../../src/types';
import type { eventWithTime } from '@rrweb/types';
@@ -59,29 +61,6 @@ describe('e2e webgl', () => {
);
};
const fakeGoto = async (p: puppeteer.Page, url: string) => {
const intercept = async (request: puppeteer.HTTPRequest) => {
await request.respond({
status: 200,
contentType: 'text/html',
body: ' ', // non-empty string or page will load indefinitely
});
};
await p.setRequestInterception(true);
p.on('request', intercept);
await p.goto(url);
p.off('request', intercept);
await p.setRequestInterception(false);
};
const hideMouseAnimation = async (p: puppeteer.Page) => {
await p.addStyleTag({
content: `.replayer-mouse-tail{display: none !important;}
html, body { margin: 0; padding: 0; }
iframe { border: none; }`,
});
};
it('will record and replay a webgl square', async () => {
page = await browser.newPage();
await fakeGoto(page, `${serverURL}/html/canvas-webgl-square.html`);

View File

@@ -0,0 +1,550 @@
import type { eventWithTime } from '@rrweb/types';
const events: eventWithTime[] = [
{ type: 0, data: {}, timestamp: 1900000001 },
{ type: 1, data: {}, timestamp: 1900000132 },
{
type: 4,
data: {
href: 'http://127.0.0.1:5500/test/html/video.html',
width: 1600,
height: 900,
},
timestamp: 1900000132,
},
{
type: 2,
data: {
node: {
type: 0,
childNodes: [
{ type: 1, name: 'html', publicId: '', systemId: '', id: 2 },
{
type: 2,
tagName: 'html',
attributes: { lang: 'en' },
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 5 },
{
type: 2,
tagName: 'meta',
attributes: { charset: 'UTF-8' },
childNodes: [],
id: 6,
},
{ type: 3, textContent: '\n ', id: 7 },
{
type: 2,
tagName: 'meta',
attributes: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: 'Video', id: 13 }],
id: 12,
},
{ type: 3, textContent: '\n ', id: 14 },
{
type: 2,
tagName: 'script',
attributes: { type: 'text/javascript' },
childNodes: [
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 16 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 17 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 18 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 19 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 20 },
],
id: 15,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'h1',
attributes: {},
childNodes: [
{ type: 3, textContent: 'Big Buck Bunny', id: 25 },
],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'video',
attributes: {
muted: '',
controls: '',
loop: '',
rr_mediaState: 'played',
rr_mediaCurrentTime: 0,
rr_mediaPlaybackRate: 1,
rr_mediaMuted: true,
rr_mediaVolume: 1,
rr_mediaLoop: true,
},
childNodes: [
{ type: 3, textContent: '\n ', id: 28 },
{
type: 2,
tagName: 'source',
attributes: {
src: '/html/assets/bunny-video.webm',
type: 'video/webm',
},
childNodes: [],
id: 29,
},
{
type: 3,
textContent:
'\n Your browser does not support the video element.\n ',
id: 30,
},
],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: 1900000136,
},
{
type: 3,
data: { source: 1, positions: [{ x: 955, y: 869, id: 3, timeOffset: 0 }] },
timestamp: 1900006165,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 902, y: 823, id: 3, timeOffset: -451 },
{ x: 840, y: 765, id: 3, timeOffset: -400 },
{ x: 796, y: 724, id: 3, timeOffset: -350 },
{ x: 716, y: 673, id: 3, timeOffset: -267 },
{ x: 660, y: 645, id: 3, timeOffset: -217 },
{ x: 554, y: 593, id: 3, timeOffset: -167 },
{ x: 466, y: 518, id: 3, timeOffset: -101 },
{ x: 433, y: 413, id: 27, timeOffset: -51 },
{ x: 432, y: 316, id: 27, timeOffset: 0 },
],
},
timestamp: 1900006665,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 444, y: 229, id: 27, timeOffset: -435 },
{ x: 444, y: 210, id: 27, timeOffset: -385 },
{ x: 444, y: 209, id: 27, timeOffset: -335 },
{ x: 445, y: 214, id: 27, timeOffset: -235 },
{ x: 460, y: 246, id: 27, timeOffset: -185 },
{ x: 476, y: 273, id: 27, timeOffset: -134 },
{ x: 482, y: 281, id: 27, timeOffset: -84 },
],
},
timestamp: 1900007166,
},
{
type: 3,
data: { source: 1, positions: [{ x: 483, y: 281, id: 27, timeOffset: 0 }] },
timestamp: 1900007814,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 483, y: 281, id: 27, timeOffset: -417 },
{ x: 484, y: 282, id: 27, timeOffset: -218 },
],
},
timestamp: 1900008315,
},
{
type: 3,
data: { source: 1, positions: [{ x: 484, y: 281, id: 27, timeOffset: 0 }] },
timestamp: 1900010165,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 484, y: 281, id: 27, timeOffset: -322 },
{ x: 485, y: 281, id: 27, timeOffset: -256 },
{ x: 491, y: 282, id: 27, timeOffset: -205 },
{ x: 515, y: 283, id: 27, timeOffset: -156 },
{ x: 534, y: 285, id: 27, timeOffset: -106 },
{ x: 562, y: 291, id: 27, timeOffset: -56 },
{ x: 575, y: 295, id: 27, timeOffset: -6 },
],
},
timestamp: 1900010670,
},
{
type: 3,
data: { source: 1, positions: [{ x: 576, y: 296, id: 27, timeOffset: 0 }] },
timestamp: 1900012714,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 576, y: 298, id: 27, timeOffset: -451 },
{ x: 577, y: 301, id: 27, timeOffset: -400 },
{ x: 578, y: 305, id: 27, timeOffset: -334 },
{ x: 580, y: 313, id: 27, timeOffset: -284 },
{ x: 582, y: 332, id: 27, timeOffset: -234 },
{ x: 585, y: 346, id: 27, timeOffset: -184 },
{ x: 587, y: 353, id: 27, timeOffset: -133 },
],
},
timestamp: 1900013215,
},
{
type: 3,
data: {
source: 1,
positions: [{ x: 587, y: 354, id: 27, timeOffset: -253 }],
},
timestamp: 1900013717,
},
{
type: 3,
data: { source: 1, positions: [{ x: 587, y: 355, id: 27, timeOffset: 0 }] },
timestamp: 1900014364,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 588, y: 358, id: 27, timeOffset: -451 },
{ x: 589, y: 368, id: 27, timeOffset: -400 },
{ x: 592, y: 386, id: 27, timeOffset: -334 },
{ x: 596, y: 402, id: 27, timeOffset: -266 },
{ x: 597, y: 410, id: 27, timeOffset: -202 },
{ x: 595, y: 415, id: 27, timeOffset: -152 },
{ x: 591, y: 417, id: 27, timeOffset: -101 },
{ x: 585, y: 418, id: 27, timeOffset: -51 },
{ x: 579, y: 418, id: 27, timeOffset: 0 },
],
},
timestamp: 1900014865,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 562, y: 420, id: 27, timeOffset: -433 },
{ x: 547, y: 422, id: 27, timeOffset: -367 },
{ x: 538, y: 423, id: 27, timeOffset: -317 },
{ x: 525, y: 423, id: 27, timeOffset: -251 },
{ x: 509, y: 423, id: 27, timeOffset: -201 },
{ x: 488, y: 422, id: 27, timeOffset: -151 },
{ x: 440, y: 421, id: 27, timeOffset: -101 },
{ x: 373, y: 419, id: 27, timeOffset: -51 },
{ x: 296, y: 415, id: 27, timeOffset: -1 },
],
},
timestamp: 1900015365,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 225, y: 409, id: 27, timeOffset: -439 },
{ x: 183, y: 408, id: 27, timeOffset: -389 },
{ x: 138, y: 407, id: 27, timeOffset: -340 },
{ x: 98, y: 406, id: 27, timeOffset: -290 },
{ x: 79, y: 406, id: 27, timeOffset: -238 },
{ x: 60, y: 406, id: 27, timeOffset: -173 },
{ x: 53, y: 406, id: 27, timeOffset: -122 },
{ x: 39, y: 405, id: 27, timeOffset: -55 },
],
},
timestamp: 1900015870,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 34, y: 403, id: 27, timeOffset: -493 },
{ x: 31, y: 401, id: 27, timeOffset: -442 },
{ x: 29, y: 399, id: 27, timeOffset: -375 },
{ x: 28, y: 399, id: 27, timeOffset: -325 },
{ x: 28, y: 397, id: 27, timeOffset: -259 },
{ x: 28, y: 394, id: 27, timeOffset: -209 },
{ x: 28, y: 394, id: 27, timeOffset: -159 },
{ x: 28, y: 393, id: 27, timeOffset: -109 },
],
},
timestamp: 1900016373,
},
{
type: 3,
data: { source: 1, positions: [{ x: 29, y: 393, id: 27, timeOffset: 0 }] },
timestamp: 1900018598,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 30, y: 391, id: 27, timeOffset: -433 },
{ x: 31, y: 392, id: 27, timeOffset: -251 },
{ x: 30, y: 393, id: 27, timeOffset: -201 },
{ x: 30, y: 394, id: 27, timeOffset: -151 },
],
},
timestamp: 1900019098,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 30, y: 394, id: 27, timeOffset: -457 },
{ x: 30, y: 394, id: 27, timeOffset: -391 },
],
},
timestamp: 1900019605,
},
{ type: 3, data: { source: 2, type: 5, id: 27 }, timestamp: 1900020571 },
{
type: 3,
data: { source: 1, positions: [{ x: 32, y: 394, id: 27, timeOffset: 0 }] },
timestamp: 1900021531,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 48, y: 394, id: 27, timeOffset: -449 },
{ x: 286, y: 413, id: 27, timeOffset: -384 },
{ x: 418, y: 419, id: 27, timeOffset: -334 },
{ x: 474, y: 419, id: 27, timeOffset: -284 },
{ x: 482, y: 418, id: 27, timeOffset: -233 },
{ x: 482, y: 417, id: 27, timeOffset: -167 },
{ x: 477, y: 416, id: 27, timeOffset: -116 },
{ x: 439, y: 414, id: 27, timeOffset: -51 },
{ x: 402, y: 412, id: 27, timeOffset: 0 },
],
},
timestamp: 1900022031,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 376, y: 413, id: 27, timeOffset: -450 },
{ x: 366, y: 414, id: 27, timeOffset: -400 },
{ x: 353, y: 416, id: 27, timeOffset: -334 },
{ x: 346, y: 417, id: 27, timeOffset: -283 },
{ x: 339, y: 419, id: 27, timeOffset: -233 },
{ x: 322, y: 422, id: 27, timeOffset: -167 },
{ x: 311, y: 422, id: 27, timeOffset: -117 },
{ x: 308, y: 422, id: 27, timeOffset: -51 },
{ x: 311, y: 420, id: 27, timeOffset: 0 },
],
},
timestamp: 1900022531,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 315, y: 419, id: 27, timeOffset: -448 },
{ x: 316, y: 418, id: 27, timeOffset: -397 },
{ x: 317, y: 417, id: 27, timeOffset: -347 },
{ x: 317, y: 417, id: 27, timeOffset: -281 },
],
},
timestamp: 1900023045,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 318, y: 418, id: 27, timeOffset: -466 },
{ x: 326, y: 439, id: 27, timeOffset: -416 },
{ x: 333, y: 473, id: 3, timeOffset: -365 },
{ x: 334, y: 484, id: 3, timeOffset: -300 },
{ x: 334, y: 485, id: 3, timeOffset: -50 },
],
},
timestamp: 1900023547,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 333, y: 485, id: 3, timeOffset: -483 },
{ x: 321, y: 481, id: 3, timeOffset: -433 },
{ x: 265, y: 460, id: 3, timeOffset: -383 },
{ x: 203, y: 433, id: 27, timeOffset: -332 },
{ x: 135, y: 402, id: 27, timeOffset: -283 },
{ x: 86, y: 387, id: 27, timeOffset: -216 },
{ x: 70, y: 384, id: 27, timeOffset: -166 },
{ x: 58, y: 381, id: 27, timeOffset: -100 },
{ x: 53, y: 381, id: 27, timeOffset: -33 },
],
},
timestamp: 1900024047,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 49, y: 383, id: 27, timeOffset: -468 },
{ x: 39, y: 387, id: 27, timeOffset: -418 },
{ x: 31, y: 389, id: 27, timeOffset: -367 },
{ x: 28, y: 390, id: 27, timeOffset: -301 },
],
},
timestamp: 1900024548,
},
{
type: 3,
data: { source: 1, positions: [{ x: 28, y: 390, id: 27, timeOffset: 0 }] },
timestamp: 1900034631,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 29, y: 393, id: 27, timeOffset: -459 },
{ x: 262, y: 474, id: 3, timeOffset: -376 },
{ x: 562, y: 573, id: 3, timeOffset: -326 },
{ x: 702, y: 603, id: 3, timeOffset: -260 },
{ x: 714, y: 603, id: 3, timeOffset: -209 },
{ x: 716, y: 600, id: 3, timeOffset: -159 },
{ x: 717, y: 597, id: 3, timeOffset: -109 },
],
},
timestamp: 1900035140,
},
{
type: 3,
data: { source: 1, positions: [{ x: 718, y: 596, id: 3, timeOffset: 0 }] },
timestamp: 1900035963,
},
{
type: 3,
data: {
source: 1,
positions: [{ x: 719, y: 594, id: 3, timeOffset: -451 }],
},
timestamp: 1900036464,
},
{
type: 3,
data: { source: 1, positions: [{ x: 722, y: 594, id: 3, timeOffset: 0 }] },
timestamp: 1900037931,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 772, y: 588, id: 3, timeOffset: -438 },
{ x: 850, y: 577, id: 3, timeOffset: -371 },
{ x: 879, y: 576, id: 3, timeOffset: -321 },
{ x: 914, y: 576, id: 3, timeOffset: -255 },
{ x: 926, y: 577, id: 3, timeOffset: -205 },
{ x: 932, y: 579, id: 3, timeOffset: -154 },
{ x: 935, y: 582, id: 3, timeOffset: -88 },
{ x: 945, y: 587, id: 3, timeOffset: -22 },
],
},
timestamp: 1900038435,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 966, y: 593, id: 3, timeOffset: -483 },
{ x: 1006, y: 601, id: 3, timeOffset: -433 },
{ x: 1075, y: 608, id: 3, timeOffset: -383 },
{ x: 1098, y: 610, id: 3, timeOffset: -333 },
{ x: 1102, y: 611, id: 3, timeOffset: -283 },
{ x: 1102, y: 611, id: 3, timeOffset: -217 },
{ x: 1103, y: 612, id: 3, timeOffset: -166 },
{ x: 1103, y: 616, id: 3, timeOffset: -100 },
],
},
timestamp: 1900038947,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 1103, y: 616, id: 3, timeOffset: -151 },
{ x: 1103, y: 616, id: 3, timeOffset: -52 },
{ x: 1103, y: 614, id: 3, timeOffset: 0 },
],
},
timestamp: 1900039448,
},
];
export default events;

View File

@@ -0,0 +1,628 @@
import type { eventWithTime } from '@rrweb/types';
const events: eventWithTime[] = [
{ type: 0, data: {}, timestamp: 1900000001 },
{ type: 1, data: {}, timestamp: 1900000132 },
{
type: 4,
data: {
href: 'http://127.0.0.1:5500/test/html/video.html',
width: 1600,
height: 900,
},
timestamp: 1900000132,
},
{
type: 2,
data: {
node: {
type: 0,
childNodes: [
{ type: 1, name: 'html', publicId: '', systemId: '', id: 2 },
{
type: 2,
tagName: 'html',
attributes: { lang: 'en' },
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 5 },
{
type: 2,
tagName: 'meta',
attributes: { charset: 'UTF-8' },
childNodes: [],
id: 6,
},
{ type: 3, textContent: '\n ', id: 7 },
{
type: 2,
tagName: 'meta',
attributes: {
'http-equiv': 'X-UA-Compatible',
content: 'IE=edge',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 10,
},
{ type: 3, textContent: '\n ', id: 11 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: 'Video', id: 13 }],
id: 12,
},
{ type: 3, textContent: '\n ', id: 14 },
{
type: 2,
tagName: 'script',
attributes: { type: 'text/javascript' },
childNodes: [
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 16 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 17 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 18 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 19 },
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 20 },
],
id: 15,
},
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 21 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 23 },
{
type: 2,
tagName: 'h1',
attributes: {},
childNodes: [
{ type: 3, textContent: 'Big Buck Bunny', id: 25 },
],
id: 24,
},
{ type: 3, textContent: '\n ', id: 26 },
{
type: 2,
tagName: 'video',
attributes: {
muted: '',
controls: '',
rr_mediaState: 'played',
rr_mediaCurrentTime: 0,
rr_mediaPlaybackRate: 1,
rr_mediaMuted: true,
rr_mediaVolume: 1,
},
childNodes: [
{ type: 3, textContent: '\n ', id: 28 },
{
type: 2,
tagName: 'source',
attributes: {
src: '/html/assets/bunny-video.webm',
type: 'video/webm',
},
childNodes: [],
id: 29,
},
{
type: 3,
textContent:
'\n Your browser does not support the video element.\n ',
id: 30,
},
],
id: 27,
},
{ type: 3, textContent: '\n ', id: 31 },
{
type: 5,
textContent: ' Code injected by live-server ',
id: 32,
},
{ type: 3, textContent: '\n', id: 33 },
{
type: 2,
tagName: 'script',
attributes: {},
childNodes: [
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 35 },
],
id: 34,
},
{ type: 3, textContent: '\n\n\n', id: 36 },
],
id: 22,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: 1900000136,
},
{
type: 3,
data: {
source: 7,
type: 0,
id: 27,
currentTime: 0.000322,
volume: 1,
muted: true,
playbackRate: 1,
},
timestamp: 1900001500,
},
{
type: 3,
data: { source: 1, positions: [{ x: 955, y: 869, id: 3, timeOffset: 0 }] },
timestamp: 1900006165,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 902, y: 823, id: 3, timeOffset: -451 },
{ x: 840, y: 765, id: 3, timeOffset: -400 },
{ x: 796, y: 724, id: 3, timeOffset: -350 },
{ x: 716, y: 673, id: 3, timeOffset: -267 },
{ x: 660, y: 645, id: 3, timeOffset: -217 },
{ x: 554, y: 593, id: 3, timeOffset: -167 },
{ x: 466, y: 518, id: 3, timeOffset: -101 },
{ x: 433, y: 413, id: 27, timeOffset: -51 },
{ x: 432, y: 316, id: 27, timeOffset: 0 },
],
},
timestamp: 1900006665,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 444, y: 229, id: 27, timeOffset: -435 },
{ x: 444, y: 210, id: 27, timeOffset: -385 },
{ x: 444, y: 209, id: 27, timeOffset: -335 },
{ x: 445, y: 214, id: 27, timeOffset: -235 },
{ x: 460, y: 246, id: 27, timeOffset: -185 },
{ x: 476, y: 273, id: 27, timeOffset: -134 },
{ x: 482, y: 281, id: 27, timeOffset: -84 },
],
},
timestamp: 1900007166,
},
{
type: 3,
data: { source: 1, positions: [{ x: 483, y: 281, id: 27, timeOffset: 0 }] },
timestamp: 1900007814,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 483, y: 281, id: 27, timeOffset: -417 },
{ x: 484, y: 282, id: 27, timeOffset: -218 },
],
},
timestamp: 1900008315,
},
{
type: 3,
data: { source: 1, positions: [{ x: 484, y: 281, id: 27, timeOffset: 0 }] },
timestamp: 1900010165,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 484, y: 281, id: 27, timeOffset: -322 },
{ x: 485, y: 281, id: 27, timeOffset: -256 },
{ x: 491, y: 282, id: 27, timeOffset: -205 },
{ x: 515, y: 283, id: 27, timeOffset: -156 },
{ x: 534, y: 285, id: 27, timeOffset: -106 },
{ x: 562, y: 291, id: 27, timeOffset: -56 },
{ x: 575, y: 295, id: 27, timeOffset: -6 },
],
},
timestamp: 1900010670,
},
{
type: 3,
data: { source: 1, positions: [{ x: 576, y: 296, id: 27, timeOffset: 0 }] },
timestamp: 1900012714,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 576, y: 298, id: 27, timeOffset: -451 },
{ x: 577, y: 301, id: 27, timeOffset: -400 },
{ x: 578, y: 305, id: 27, timeOffset: -334 },
{ x: 580, y: 313, id: 27, timeOffset: -284 },
{ x: 582, y: 332, id: 27, timeOffset: -234 },
{ x: 585, y: 346, id: 27, timeOffset: -184 },
{ x: 587, y: 353, id: 27, timeOffset: -133 },
],
},
timestamp: 1900013215,
},
{
type: 3,
data: {
source: 1,
positions: [{ x: 587, y: 354, id: 27, timeOffset: -253 }],
},
timestamp: 1900013717,
},
{
type: 3,
data: { source: 1, positions: [{ x: 587, y: 355, id: 27, timeOffset: 0 }] },
timestamp: 1900014364,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 588, y: 358, id: 27, timeOffset: -451 },
{ x: 589, y: 368, id: 27, timeOffset: -400 },
{ x: 592, y: 386, id: 27, timeOffset: -334 },
{ x: 596, y: 402, id: 27, timeOffset: -266 },
{ x: 597, y: 410, id: 27, timeOffset: -202 },
{ x: 595, y: 415, id: 27, timeOffset: -152 },
{ x: 591, y: 417, id: 27, timeOffset: -101 },
{ x: 585, y: 418, id: 27, timeOffset: -51 },
{ x: 579, y: 418, id: 27, timeOffset: 0 },
],
},
timestamp: 1900014865,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 562, y: 420, id: 27, timeOffset: -433 },
{ x: 547, y: 422, id: 27, timeOffset: -367 },
{ x: 538, y: 423, id: 27, timeOffset: -317 },
{ x: 525, y: 423, id: 27, timeOffset: -251 },
{ x: 509, y: 423, id: 27, timeOffset: -201 },
{ x: 488, y: 422, id: 27, timeOffset: -151 },
{ x: 440, y: 421, id: 27, timeOffset: -101 },
{ x: 373, y: 419, id: 27, timeOffset: -51 },
{ x: 296, y: 415, id: 27, timeOffset: -1 },
],
},
timestamp: 1900015365,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 225, y: 409, id: 27, timeOffset: -439 },
{ x: 183, y: 408, id: 27, timeOffset: -389 },
{ x: 138, y: 407, id: 27, timeOffset: -340 },
{ x: 98, y: 406, id: 27, timeOffset: -290 },
{ x: 79, y: 406, id: 27, timeOffset: -238 },
{ x: 60, y: 406, id: 27, timeOffset: -173 },
{ x: 53, y: 406, id: 27, timeOffset: -122 },
{ x: 39, y: 405, id: 27, timeOffset: -55 },
],
},
timestamp: 1900015870,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 34, y: 403, id: 27, timeOffset: -493 },
{ x: 31, y: 401, id: 27, timeOffset: -442 },
{ x: 29, y: 399, id: 27, timeOffset: -375 },
{ x: 28, y: 399, id: 27, timeOffset: -325 },
{ x: 28, y: 397, id: 27, timeOffset: -259 },
{ x: 28, y: 394, id: 27, timeOffset: -209 },
{ x: 28, y: 394, id: 27, timeOffset: -159 },
{ x: 28, y: 393, id: 27, timeOffset: -109 },
],
},
timestamp: 1900016373,
},
{
type: 3,
data: { source: 1, positions: [{ x: 29, y: 393, id: 27, timeOffset: 0 }] },
timestamp: 1900018598,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 30, y: 391, id: 27, timeOffset: -433 },
{ x: 31, y: 392, id: 27, timeOffset: -251 },
{ x: 30, y: 393, id: 27, timeOffset: -201 },
{ x: 30, y: 394, id: 27, timeOffset: -151 },
],
},
timestamp: 1900019098,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 30, y: 394, id: 27, timeOffset: -457 },
{ x: 30, y: 394, id: 27, timeOffset: -391 },
],
},
timestamp: 1900019605,
},
{ type: 3, data: { source: 2, type: 5, id: 27 }, timestamp: 1900020571 },
{
type: 3,
data: {
source: 7,
type: 1,
id: 27,
currentTime: 20.088367,
volume: 1,
muted: true,
playbackRate: 1,
},
timestamp: 1900020661,
},
{
type: 3,
data: { source: 1, positions: [{ x: 32, y: 394, id: 27, timeOffset: 0 }] },
timestamp: 1900021531,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 48, y: 394, id: 27, timeOffset: -449 },
{ x: 286, y: 413, id: 27, timeOffset: -384 },
{ x: 418, y: 419, id: 27, timeOffset: -334 },
{ x: 474, y: 419, id: 27, timeOffset: -284 },
{ x: 482, y: 418, id: 27, timeOffset: -233 },
{ x: 482, y: 417, id: 27, timeOffset: -167 },
{ x: 477, y: 416, id: 27, timeOffset: -116 },
{ x: 439, y: 414, id: 27, timeOffset: -51 },
{ x: 402, y: 412, id: 27, timeOffset: 0 },
],
},
timestamp: 1900022031,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 376, y: 413, id: 27, timeOffset: -450 },
{ x: 366, y: 414, id: 27, timeOffset: -400 },
{ x: 353, y: 416, id: 27, timeOffset: -334 },
{ x: 346, y: 417, id: 27, timeOffset: -283 },
{ x: 339, y: 419, id: 27, timeOffset: -233 },
{ x: 322, y: 422, id: 27, timeOffset: -167 },
{ x: 311, y: 422, id: 27, timeOffset: -117 },
{ x: 308, y: 422, id: 27, timeOffset: -51 },
{ x: 311, y: 420, id: 27, timeOffset: 0 },
],
},
timestamp: 1900022531,
},
{
type: 3,
data: {
source: 7,
type: 2,
id: 27,
currentTime: 9.744288,
volume: 1,
muted: true,
playbackRate: 1,
},
timestamp: 19000602896,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 315, y: 419, id: 27, timeOffset: -448 },
{ x: 316, y: 418, id: 27, timeOffset: -397 },
{ x: 317, y: 417, id: 27, timeOffset: -347 },
{ x: 317, y: 417, id: 27, timeOffset: -281 },
],
},
timestamp: 1900023045,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 318, y: 418, id: 27, timeOffset: -466 },
{ x: 326, y: 439, id: 27, timeOffset: -416 },
{ x: 333, y: 473, id: 3, timeOffset: -365 },
{ x: 334, y: 484, id: 3, timeOffset: -300 },
{ x: 334, y: 485, id: 3, timeOffset: -50 },
],
},
timestamp: 1900023547,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 333, y: 485, id: 3, timeOffset: -483 },
{ x: 321, y: 481, id: 3, timeOffset: -433 },
{ x: 265, y: 460, id: 3, timeOffset: -383 },
{ x: 203, y: 433, id: 27, timeOffset: -332 },
{ x: 135, y: 402, id: 27, timeOffset: -283 },
{ x: 86, y: 387, id: 27, timeOffset: -216 },
{ x: 70, y: 384, id: 27, timeOffset: -166 },
{ x: 58, y: 381, id: 27, timeOffset: -100 },
{ x: 53, y: 381, id: 27, timeOffset: -33 },
],
},
timestamp: 1900024047,
},
{
type: 3,
data: {
source: 7,
type: 0,
id: 27,
currentTime: 9.744632,
volume: 1,
muted: true,
playbackRate: 1,
},
timestamp: 1900024475,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 49, y: 383, id: 27, timeOffset: -468 },
{ x: 39, y: 387, id: 27, timeOffset: -418 },
{ x: 31, y: 389, id: 27, timeOffset: -367 },
{ x: 28, y: 390, id: 27, timeOffset: -301 },
],
},
timestamp: 1900024548,
},
{
type: 3,
data: {
source: 7,
type: 1,
id: 27,
currentTime: 19.704312,
volume: 1,
muted: true,
playbackRate: 1,
},
timestamp: 1900034476,
},
{
type: 3,
data: { source: 1, positions: [{ x: 28, y: 390, id: 27, timeOffset: 0 }] },
timestamp: 1900034631,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 29, y: 393, id: 27, timeOffset: -459 },
{ x: 262, y: 474, id: 3, timeOffset: -376 },
{ x: 562, y: 573, id: 3, timeOffset: -326 },
{ x: 702, y: 603, id: 3, timeOffset: -260 },
{ x: 714, y: 603, id: 3, timeOffset: -209 },
{ x: 716, y: 600, id: 3, timeOffset: -159 },
{ x: 717, y: 597, id: 3, timeOffset: -109 },
],
},
timestamp: 1900035140,
},
{
type: 3,
data: { source: 1, positions: [{ x: 718, y: 596, id: 3, timeOffset: 0 }] },
timestamp: 1900035963,
},
{
type: 3,
data: {
source: 1,
positions: [{ x: 719, y: 594, id: 3, timeOffset: -451 }],
},
timestamp: 1900036464,
},
{
type: 3,
data: { source: 1, positions: [{ x: 722, y: 594, id: 3, timeOffset: 0 }] },
timestamp: 1900037931,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 772, y: 588, id: 3, timeOffset: -438 },
{ x: 850, y: 577, id: 3, timeOffset: -371 },
{ x: 879, y: 576, id: 3, timeOffset: -321 },
{ x: 914, y: 576, id: 3, timeOffset: -255 },
{ x: 926, y: 577, id: 3, timeOffset: -205 },
{ x: 932, y: 579, id: 3, timeOffset: -154 },
{ x: 935, y: 582, id: 3, timeOffset: -88 },
{ x: 945, y: 587, id: 3, timeOffset: -22 },
],
},
timestamp: 1900038435,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 966, y: 593, id: 3, timeOffset: -483 },
{ x: 1006, y: 601, id: 3, timeOffset: -433 },
{ x: 1075, y: 608, id: 3, timeOffset: -383 },
{ x: 1098, y: 610, id: 3, timeOffset: -333 },
{ x: 1102, y: 611, id: 3, timeOffset: -283 },
{ x: 1102, y: 611, id: 3, timeOffset: -217 },
{ x: 1103, y: 612, id: 3, timeOffset: -166 },
{ x: 1103, y: 616, id: 3, timeOffset: -100 },
],
},
timestamp: 1900038947,
},
{
type: 3,
data: {
source: 1,
positions: [
{ x: 1103, y: 616, id: 3, timeOffset: -151 },
{ x: 1103, y: 616, id: 3, timeOffset: -52 },
{ x: 1103, y: 614, id: 3, timeOffset: 0 },
],
},
timestamp: 1900039448,
},
];
export default events;

Binary file not shown.

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Video</title>
</head>
<body>
<h1>Big Buck Bunny</h1>
<video muted controls>
<source src="assets/bunny-video.webm" type="video/webm" />
Your browser does not support the video element.
</video>
</body>
<script>
document.querySelector('video').currentTime = 5;
</script>
</html>

View File

@@ -263,7 +263,11 @@ exports[`cross origin iframes audio.html should emit contents of iframe once 1`]
\\"attributes\\": {
\\"controls\\": \\"\\",
\\"rr_mediaState\\": \\"paused\\",
\\"rr_mediaCurrentTime\\": 0
\\"rr_mediaCurrentTime\\": 0,
\\"rr_mediaPlaybackRate\\": 1,
\\"rr_mediaMuted\\": false,
\\"rr_mediaLoop\\": false,
\\"rr_mediaVolume\\": 1
},
\\"childNodes\\": [
{
@@ -327,7 +331,8 @@ exports[`cross origin iframes audio.html should emit contents of iframe once 1`]
\\"currentTime\\": 0,
\\"volume\\": 1,
\\"muted\\": false,
\\"playbackRate\\": 1
\\"playbackRate\\": 1,
\\"loop\\": false
}
}
]"

View File

@@ -0,0 +1,240 @@
import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import {
startServer,
launchPuppeteer,
getServerURL,
waitForRAF,
ISuite,
hideMouseAnimation,
fakeGoto,
} from '../utils';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import videoPlaybackEvents from '../events/video-playback';
import videoPlaybackOnFullSnapshotEvents from '../events/video-playback-on-full-snapshot';
expect.extend({ toMatchImageSnapshot });
describe('video', () => {
jest.setTimeout(100_000);
let code: ISuite['code'];
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await server.close();
await browser.close();
});
beforeEach(async () => {
page = await browser.newPage();
await fakeGoto(page, `${serverURL}/html/video.html`);
await page.evaluate(code);
await waitForRAF(page);
await hideMouseAnimation(page);
});
it('will seek to the correct moment', async () => {
await page.evaluate(`let events = ${JSON.stringify(videoPlaybackEvents)}`);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
`);
await waitForRAF(page);
await page.evaluate(`
window.replayer.pause(6500);
`);
await page.waitForNetworkIdle();
await waitForRAF(page);
const frameImage = await page!.screenshot();
await waitForRAF(page);
expect(frameImage).toMatchImageSnapshot({
failureThreshold: 0.05,
failureThresholdType: 'percent',
});
});
it('will seek to the correct moment without media interaction events', async () => {
await page.evaluate(`
let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)};
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
window.replayer.pause(6500);
`);
await page.waitForNetworkIdle();
await waitForRAF(page);
const frameImage = await page!.screenshot();
await waitForRAF(page);
expect(frameImage).toMatchImageSnapshot({
failureThreshold: 0.05,
failureThresholdType: 'percent',
});
});
it("will be paused when the player wasn't started yet", async () => {
await page.evaluate(`
let events = ${JSON.stringify(videoPlaybackEvents)};
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
`);
await page.waitForNetworkIdle();
await waitForRAF(page);
const frameImage = await page!.screenshot();
await waitForRAF(page);
expect(frameImage).toMatchImageSnapshot({
failureThreshold: 0.05,
failureThresholdType: 'percent',
});
});
it('will play from the correct moment', async () => {
await page.evaluate(`let events = ${JSON.stringify(videoPlaybackEvents)}`);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events, {
UNSAFE_replayCanvas: true,
});
`);
await waitForRAF(page);
await page.evaluate(`
window.replayer.play(6500);
`);
await page.waitForNetworkIdle();
await waitForRAF(page);
const frameImage = await page!.screenshot();
await waitForRAF(page);
expect(frameImage).toMatchImageSnapshot({
failureThreshold: 0.05,
failureThresholdType: 'percent',
});
// TODO: check to see if video is same as basic replay
});
it('should play from the start', async () => {
await page.evaluate(`let events = ${JSON.stringify(videoPlaybackEvents)}`);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
window.replayer.play();
`);
await waitForRAF(page);
await page.waitForNetworkIdle();
await waitForRAF(page);
const isPlaying = await page.evaluate(`
!document.querySelector('iframe').contentDocument.querySelector('video').paused &&
document.querySelector('iframe').contentDocument.querySelector('video').currentTime !== 0 &&
!document.querySelector('iframe').contentDocument.querySelector('video').ended;
`);
expect(isPlaying).toBe(true);
});
it('should play from the start without media events', async () => {
await page.evaluate(
`let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`,
);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
window.replayer.play();
`);
await waitForRAF(page);
await page.waitForNetworkIdle();
await waitForRAF(page);
const isPlaying = await page.evaluate(`
!document.querySelector('iframe').contentDocument.querySelector('video').paused &&
document.querySelector('iframe').contentDocument.querySelector('video').currentTime !== 0 &&
!document.querySelector('iframe').contentDocument.querySelector('video').ended;
`);
expect(isPlaying).toBe(true);
});
it('should report the correct time for looping videos that have passed their total time', async () => {
await page.evaluate(
`let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`,
);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
`);
await waitForRAF(page);
await page.waitForNetworkIdle();
await waitForRAF(page);
await page.evaluate(`
window.replayer.pause(25000); // 5 seconds after the video started a new loop
`);
await waitForRAF(page);
const time = await page.evaluate(`
document.querySelector('iframe').contentDocument.querySelector('video').currentTime;
`);
expect(time).toBeCloseTo(5, 0);
});
it('should set the correct time on loading videos', async () => {
await page.evaluate(
`let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`,
);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events);
window.replayer.pause(25000); // 5 seconds after the video started a new loop
`);
await waitForRAF(page);
await page.waitForNetworkIdle();
await waitForRAF(page);
const time = await page.evaluate(`
document.querySelector('iframe').contentDocument.querySelector('video').currentTime;
`);
expect(time).toBeCloseTo(5, 0);
});
it('should set the correct playbackRate on faster playback', async () => {
page.on('console', (msg) => {
console.log(msg.text());
});
await page.evaluate(
`let events = ${JSON.stringify(videoPlaybackOnFullSnapshotEvents)}`,
);
await page.evaluate(`
const { Replayer } = rrweb;
window.replayer = new Replayer(events, {
speed: 8,
});
window.replayer.play();
`);
await waitForRAF(page);
await page.waitForNetworkIdle();
await waitForRAF(page);
const time = await page.evaluate(`
document.querySelector('iframe').contentDocument.querySelector('video').playbackRate;
`);
expect(time).toBe(8);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -50,6 +50,7 @@ export const startServer = (defaultPort: number = 3030) =>
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.webm': 'video/webm',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
@@ -69,6 +70,7 @@ export const startServer = (defaultPort: number = 3030) =>
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
if (ext === '.webm') res.setHeader('Accept-Ranges', 'bytes');
setTimeout(() => {
res.end(data);
// mock delay
@@ -703,3 +705,26 @@ export function generateRecordSnippet(options: recordOptions<eventWithTime>) {
});
`;
}
export async function hideMouseAnimation(p: puppeteer.Page): Promise<void> {
await p.addStyleTag({
content: `.replayer-mouse-tail{display: none !important;}
html, body { margin: 0; padding: 0; }
iframe { border: none; }`,
});
}
export const fakeGoto = async (p: puppeteer.Page, url: string) => {
const intercept = async (request: puppeteer.HTTPRequest) => {
await request.respond({
status: 200,
contentType: 'text/html',
body: ' ', // non-empty string or page will load indefinitely
});
};
await p.setRequestInterception(true);
p.on('request', intercept);
await p.goto(url);
p.off('request', intercept);
await p.setRequestInterception(false);
};

View File

@@ -573,6 +573,7 @@ export type mediaInteractionParam = {
currentTime?: number;
volume?: number;
muted?: boolean;
loop?: boolean;
playbackRate?: number;
};
@@ -653,6 +654,9 @@ export type Arguments<T> = T extends (...payload: infer U) => unknown
export enum ReplayerEvents {
Start = 'start',
Pause = 'pause',
/**
* @deprecated use Play instead
*/
Resume = 'resume',
Resize = 'resize',
Finish = 'finish',