Save images offline, in the snapshot (#770)

* Implemented image restore from rr_dataURL

* Implement saving images in the snapshot

* Fixed image saving, added a test

* Rename data-src to data-rrweb-src

* Updated the guide

* Rename recordImages to inlineImages and try catch
This commit is contained in:
Cristi Constantin
2026-04-01 12:00:00 +08:00
committed by GitHub
parent dd2cdedcd6
commit 151debad4a
8 changed files with 77 additions and 6 deletions

View File

@@ -155,6 +155,7 @@ The parameter of `rrweb.record` accepts the following options.
| packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | | packFn | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
| sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) | | sampling | - | refer to the [storage optimization recipe](./docs/recipes/optimize-storage.md) |
| recordCanvas | false | whether to record the canvas element | | recordCanvas | false | whether to record the canvas element |
| inlineImages | false | whether to record the image content |
| collectFonts | false | whether to collect fonts in the website | | collectFonts | false | whether to collect fonts in the website |
| recordLog | false | whether to record console output, refer to the [console recipe](./docs/recipes/console.md) | | recordLog | false | whether to record console output, refer to the [console recipe](./docs/recipes/console.md) |
| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) | | userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) |

View File

@@ -226,18 +226,24 @@ function buildNode(
ctx.drawImage(image, 0, 0, image.width, image.height); ctx.drawImage(image, 0, 0, image.width, image.height);
} }
}; };
} else if (tagName === 'img' && name === 'rr_dataURL') {
const image = (node as HTMLImageElement);
if (!image.currentSrc.startsWith('data:')) {
// backup original img src
image.setAttribute('data-rrweb-src', image.currentSrc);
image.src = value;
}
image.removeAttribute(name);
} }
if (name === 'rr_width') { if (name === 'rr_width') {
(node as HTMLElement).style.width = value; (node as HTMLElement).style.width = value;
} } else if (name === 'rr_height') {
if (name === 'rr_height') {
(node as HTMLElement).style.height = value; (node as HTMLElement).style.height = value;
} } else if (name === 'rr_mediaCurrentTime') {
if (name === 'rr_mediaCurrentTime') {
(node as HTMLMediaElement).currentTime = n.attributes (node as HTMLMediaElement).currentTime = n.attributes
.rr_mediaCurrentTime as number; .rr_mediaCurrentTime as number;
} } else if (name === 'rr_mediaState') {
if (name === 'rr_mediaState') {
switch (value) { switch (value) {
case 'played': case 'played':
(node as HTMLMediaElement) (node as HTMLMediaElement)

View File

@@ -75,6 +75,20 @@ function extractOrigin(url: string): string {
return origin; return origin;
} }
let canvasService: HTMLCanvasElement | null;
let canvasCtx: CanvasRenderingContext2D | null;
function initCanvasService(doc: Document) {
if (!canvasService) {
canvasService = doc.createElement('canvas');
}
if (!canvasCtx) {
canvasCtx = canvasService.getContext('2d');
}
canvasService.width = 0;
canvasService.height = 0;
}
const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm; const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm;
const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/; const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/;
const DATA_URI = /^(data:)([^,]*),(.*)/i; const DATA_URI = /^(data:)([^,]*),(.*)/i;
@@ -369,6 +383,7 @@ function serializeNode(
maskInputOptions: MaskInputOptions; maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined; maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined; maskInputFn: MaskInputFn | undefined;
inlineImages: boolean;
recordCanvas: boolean; recordCanvas: boolean;
keepIframeSrcFn: KeepIframeSrcFn; keepIframeSrcFn: KeepIframeSrcFn;
}, },
@@ -383,6 +398,7 @@ function serializeNode(
maskInputOptions = {}, maskInputOptions = {},
maskTextFn, maskTextFn,
maskInputFn, maskInputFn,
inlineImages,
recordCanvas, recordCanvas,
keepIframeSrcFn, keepIframeSrcFn,
} = options; } = options;
@@ -498,6 +514,19 @@ function serializeNode(
if (tagName === 'canvas' && recordCanvas) { if (tagName === 'canvas' && recordCanvas) {
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(); attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();
} }
// save image offline
if (tagName === 'img' && inlineImages && canvasService && canvasCtx) {
const image = (n as HTMLImageElement);
image.crossOrigin = 'anonymous';
try {
canvasService.width = image.naturalWidth;
canvasService.height = image.naturalHeight;
canvasCtx.drawImage(image, 0, 0);
attributes.rr_dataURL = canvasService.toDataURL();
} catch {
// ignore error
}
}
// media elements // media elements
if (tagName === 'audio' || tagName === 'video') { if (tagName === 'audio' || tagName === 'video') {
attributes.rr_mediaState = (n as HTMLMediaElement).paused attributes.rr_mediaState = (n as HTMLMediaElement).paused
@@ -711,6 +740,7 @@ export function serializeNodeWithId(
maskInputFn: MaskInputFn | undefined; maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions; slimDOMOptions: SlimDOMOptions;
keepIframeSrcFn?: KeepIframeSrcFn; keepIframeSrcFn?: KeepIframeSrcFn;
inlineImages?: boolean;
recordCanvas?: boolean; recordCanvas?: boolean;
preserveWhiteSpace?: boolean; preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown; onSerialize?: (n: INode) => unknown;
@@ -731,6 +761,7 @@ export function serializeNodeWithId(
maskTextFn, maskTextFn,
maskInputFn, maskInputFn,
slimDOMOptions, slimDOMOptions,
inlineImages = false,
recordCanvas = false, recordCanvas = false,
onSerialize, onSerialize,
onIframeLoad, onIframeLoad,
@@ -748,6 +779,7 @@ export function serializeNodeWithId(
maskInputOptions, maskInputOptions,
maskTextFn, maskTextFn,
maskInputFn, maskInputFn,
inlineImages,
recordCanvas, recordCanvas,
keepIframeSrcFn, keepIframeSrcFn,
}); });
@@ -800,6 +832,9 @@ export function serializeNodeWithId(
) { ) {
preserveWhiteSpace = false; preserveWhiteSpace = false;
} }
if (inlineImages) {
initCanvasService(doc);
}
const bypassOptions = { const bypassOptions = {
doc, doc,
map, map,
@@ -813,6 +848,7 @@ export function serializeNodeWithId(
maskTextFn, maskTextFn,
maskInputFn, maskInputFn,
slimDOMOptions, slimDOMOptions,
inlineImages,
recordCanvas, recordCanvas,
preserveWhiteSpace, preserveWhiteSpace,
onSerialize, onSerialize,
@@ -865,6 +901,7 @@ export function serializeNodeWithId(
maskTextFn, maskTextFn,
maskInputFn, maskInputFn,
slimDOMOptions, slimDOMOptions,
inlineImages,
recordCanvas, recordCanvas,
preserveWhiteSpace, preserveWhiteSpace,
onSerialize, onSerialize,
@@ -897,6 +934,7 @@ function snapshot(
maskTextFn?: MaskTextFn; maskTextFn?: MaskTextFn;
maskInputFn?: MaskTextFn; maskInputFn?: MaskTextFn;
slimDOM?: boolean | SlimDOMOptions; slimDOM?: boolean | SlimDOMOptions;
inlineImages?: boolean;
recordCanvas?: boolean; recordCanvas?: boolean;
preserveWhiteSpace?: boolean; preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown; onSerialize?: (n: INode) => unknown;
@@ -911,6 +949,7 @@ function snapshot(
maskTextClass = 'rr-mask', maskTextClass = 'rr-mask',
maskTextSelector = null, maskTextSelector = null,
inlineStylesheet = true, inlineStylesheet = true,
inlineImages = false,
recordCanvas = false, recordCanvas = false,
maskAllInputs = false, maskAllInputs = false,
maskTextFn, maskTextFn,
@@ -980,6 +1019,7 @@ function snapshot(
maskTextFn, maskTextFn,
maskInputFn, maskInputFn,
slimDOMOptions, slimDOMOptions,
inlineImages,
recordCanvas, recordCanvas,
preserveWhiteSpace, preserveWhiteSpace,
onSerialize, onSerialize,

View File

@@ -326,6 +326,7 @@ exports[`integration tests [html file]: picture.html 1`] = `
<source type=\\"image/webp\\" srcset=\\"http://localhost:3030/assets/img/characters/robot.webp\\" /> <source type=\\"image/webp\\" srcset=\\"http://localhost:3030/assets/img/characters/robot.webp\\" />
<img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" /> <img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" />
</picture> </picture>
<img src=\\"http://localhost:3030/images/robot.png\\" alt=\\"This is a robot\\" />
</body></html>" </body></html>"
`; `;

View File

@@ -4,5 +4,6 @@
<source type="image/webp" srcset="assets/img/characters/robot.webp" /> <source type="image/webp" srcset="assets/img/characters/robot.webp" />
<img src="assets/img/characters/robot.png" /> <img src="assets/img/characters/robot.png" />
</picture> </picture>
<img src="/images/robot.png" alt="This is a robot" />
</body> </body>
</html> </html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -28,6 +28,7 @@ const startServer = () =>
'.html': 'text/html', '.html': 'text/html',
'.js': 'text/javascript', '.js': 'text/javascript',
'.css': 'text/css', '.css': 'text/css',
'.png': 'image/png',
}; };
const s = http.createServer((req, res) => { const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!); const parsedUrl = url.parse(req.url!);
@@ -190,6 +191,25 @@ iframe.contentDocument.querySelector('center').clientHeight
'rebuilt height (${rebuildRenderedHeight}) should equal original height (${renderedHeight})', 'rebuilt height (${rebuildRenderedHeight}) should equal original height (${renderedHeight})',
); );
}); });
it('correctly saves images offline', async () => {
const page: puppeteer.Page = await browser.newPage();
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto('http://localhost:3030/html/picture.html', { waitUntil: 'load' });
await page.waitForSelector('img', { timeout: 1000 });
const snapshot = await page.evaluate(`${code}
const [snap] = rrweb.snapshot(document, {inlineImages: true, inlineStylesheet: false});
JSON.stringify(snap, null, 2);
`);
assert(snapshot.includes('"rr_dataURL"'));
assert(snapshot.includes('data:image/png;base64,'));
});
}); });
describe('iframe integration tests', function (this: ISuite) { describe('iframe integration tests', function (this: ISuite) {

View File

@@ -19,6 +19,7 @@ export declare function serializeNodeWithId(n: Node | INode, options: {
maskInputFn: MaskInputFn | undefined; maskInputFn: MaskInputFn | undefined;
slimDOMOptions: SlimDOMOptions; slimDOMOptions: SlimDOMOptions;
keepIframeSrcFn?: KeepIframeSrcFn; keepIframeSrcFn?: KeepIframeSrcFn;
inlineImages?: boolean;
recordCanvas?: boolean; recordCanvas?: boolean;
preserveWhiteSpace?: boolean; preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown; onSerialize?: (n: INode) => unknown;
@@ -35,6 +36,7 @@ declare function snapshot(n: Document, options?: {
maskTextFn?: MaskTextFn; maskTextFn?: MaskTextFn;
maskInputFn?: MaskTextFn; maskInputFn?: MaskTextFn;
slimDOM?: boolean | SlimDOMOptions; slimDOM?: boolean | SlimDOMOptions;
inlineImages?: boolean;
recordCanvas?: boolean; recordCanvas?: boolean;
preserveWhiteSpace?: boolean; preserveWhiteSpace?: boolean;
onSerialize?: (n: INode) => unknown; onSerialize?: (n: INode) => unknown;