Skip to content

Commit 63e4ee7

Browse files
authored
perf: draw each frame of a looping html5 page once (#269)
1 parent 6555fd7 commit 63e4ee7

2 files changed

Lines changed: 66 additions & 12 deletions

File tree

‎src/components/canvas/players/html5-player.ts‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ const CAPTURE_SLICE_MS = 12;
3535
const CAPTURE_MEMORY_BUDGET_BYTES = 256 * 1024 * 1024;
3636
const CAPTURE_PROGRESS_STEPS = 20;
3737
const BAKE_SCALES = [0.25, 0.5, 0.75, 1];
38+
// A remembered frame holds its body markup, so a window bounds the memory. 300 frames covers a 10 s loop at 30 fps.
39+
const REPEAT_WINDOW_FRAMES = 300;
40+
const ANIMATION_OVERRIDE = `<style>*,*::before,*::after{animation:none!important;transition:none!important}</style>`;
41+
42+
function foreignObjectSvg(width: number, height: number, styles: string, body: string): string {
43+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${ANIMATION_OVERRIDE}${body}</foreignObject></svg>`;
44+
}
3845

3946
function forceLayout(el: HTMLElement): void {
4047
el.getBoundingClientRect();
@@ -461,9 +468,9 @@ export class Html5Player extends Player {
461468
const framesInFlight = captureFramesInFlight(navigator.hardwareConcurrency, Math.trunc(W * scale) * Math.trunc(H * scale) * 4);
462469
const blobs: Blob[] = [];
463470
const pending: Promise<Blob>[] = [];
464-
// The SVG is a frame's whole input, so an identical one draws identical bytes and shares the previous frame's Blob.
465-
let lastSvg = "";
466-
let lastFrame: Promise<Blob> | null = null;
471+
// The SVG is a frame's whole input, so a frame that repeats a recent one draws identical bytes and shares its Blob.
472+
const recent = new Map<string, Promise<Blob>>();
473+
let recentStyles = "";
467474
let issued = 0;
468475
let progressStep = 0;
469476
let sliceStart = -Infinity;
@@ -477,14 +484,23 @@ export class Html5Player extends Player {
477484
}
478485
this.seekHarness(issued / fps);
479486
forceLayout(this.iframe.contentDocument.body);
480-
const svg = this.captureIframeAsForeignObjectSvg(W, H);
481-
if (!lastFrame || svg !== lastSvg) {
482-
lastSvg = svg;
483-
lastFrame = foreignObjectSvgToWebp(svg, W, H, scale).then(bytes => new Blob([bytes as BlobPart], { type: "image/webp" }));
487+
const { styles, body } = this.serialiseIframe(W, H);
488+
if (styles !== recentStyles) {
489+
recent.clear();
490+
recentStyles = styles;
491+
}
492+
let drawn = recent.get(body);
493+
if (!drawn) {
494+
drawn = foreignObjectSvgToWebp(foreignObjectSvg(W, H, styles, body), W, H, scale).then(
495+
bytes => new Blob([bytes as BlobPart], { type: "image/webp" })
496+
);
484497
// Marks a queued frame's failure handled; awaiting it rethrows.
485-
lastFrame.catch(() => undefined);
498+
drawn.catch(() => undefined);
499+
// A Map iterates in insertion order, so its first key is the oldest frame.
500+
if (recent.size >= REPEAT_WINDOW_FRAMES) recent.delete(recent.keys().next().value!);
501+
recent.set(body, drawn);
486502
}
487-
pending.push(lastFrame);
503+
pending.push(drawn);
488504
issued += 1;
489505
} else {
490506
const frame = await pending.shift()!;
@@ -554,13 +570,19 @@ export class Html5Player extends Player {
554570
}
555571

556572
private captureIframeAsForeignObjectSvg(width: number, height: number, iframe: HTMLIFrameElement | null = this.iframe): string {
573+
const { styles, body } = this.serialiseIframe(width, height, iframe);
574+
return foreignObjectSvg(width, height, styles, body);
575+
}
576+
577+
// The styles carry the page's inlined fonts and images, hundreds of KB that rarely change between frames, so a
578+
// bake compares them apart from the body.
579+
private serialiseIframe(width: number, height: number, iframe: HTMLIFrameElement | null = this.iframe): { styles: string; body: string } {
557580
if (!iframe?.contentDocument) throw new Error("iframe not ready");
558581
const doc = iframe.contentDocument;
559582
const serializer = new XMLSerializer();
560583
const styles = Array.from(doc.querySelectorAll("style"))
561584
.map(el => serializer.serializeToString(el))
562585
.join("");
563-
const animationOverride = `<style>*,*::before,*::after{animation:none!important;transition:none!important}</style>`;
564586
const bodyClone = doc.body.cloneNode(true) as HTMLElement;
565587
// Scripts never run in an SVG image, and their source can hold characters XML forbids, which fail the frame.
566588
bodyClone.querySelectorAll("script").forEach(script => script.remove());
@@ -588,8 +610,7 @@ export class Html5Player extends Player {
588610
}
589611
const existingStyle = bodyClone.getAttribute("style") ?? "";
590612
bodyClone.setAttribute("style", `width:${width}px;height:${height}px;margin:0;overflow:hidden;${existingStyle}`);
591-
const bodyXml = serializer.serializeToString(bodyClone);
592-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${animationOverride}${bodyXml}</foreignObject></svg>`;
613+
return { styles, body: serializer.serializeToString(bodyClone) };
593614
}
594615

595616
private async getDecodedFrame(idx: number): Promise<pixi.Texture | null> {

‎tests/html5-player.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,39 @@ it("draws a frame only when the page changed, reusing the last frame's blob", as
753753
expect(await frameMarkup(frames[59])).toContain('data-t="0.5"');
754754
});
755755

756+
it("draws each frame of a looping page once, reusing it every time the loop comes round", async () => {
757+
const { harness, iframe } = createStillPlayer(); // 2 s at 30 fps: 60 frames
758+
jest.mocked(harness.seekHarness).mockImplementation((seconds, win = iframe.contentWindow) => {
759+
const { body } = win!.document;
760+
body.textContent = `phase=${Math.round(seconds * 30) % 15}`;
761+
});
762+
const toBlob = jest.mocked(HTMLCanvasElement.prototype.toBlob);
763+
toBlob.mockClear();
764+
765+
const frames = (await harness.runCapture())!;
766+
767+
expect(toBlob).toHaveBeenCalledTimes(15);
768+
expect(frames[45]).toBe(frames[0]);
769+
expect(frames[59]).toBe(frames[14]);
770+
expect(await frameMarkup(frames[59])).toContain(">phase=14<");
771+
});
772+
773+
it("shares no frame between pages that differ only in their styles", async () => {
774+
const { harness, iframe } = createStillPlayer(); // 2 s at 30 fps: 60 frames
775+
jest.mocked(harness.seekHarness).mockImplementation((seconds, win = iframe.contentWindow) => {
776+
const doc = win!.document;
777+
const style = doc.querySelector("style") ?? doc.head.appendChild(doc.createElement("style"));
778+
style.textContent = seconds >= 2 / 3 && seconds < 4 / 3 ? ".b{}" : ".a{}";
779+
doc.body.textContent = "same";
780+
});
781+
782+
const frames = (await harness.runCapture())!;
783+
784+
expect(await frameMarkup(frames[30])).toContain(".b{}");
785+
expect(await frameMarkup(frames[59])).toContain(".a{}");
786+
expect(frames[59]).not.toBe(frames[30]);
787+
});
788+
756789
it.each([
757790
[undefined, 4],
758791
[2, 4],

0 commit comments

Comments
 (0)