From e49ff6c855b2a1a6cf5b68048fe3b2bdaeea2d3b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 28 Oct 2026 09:15:05 +1100 Subject: [PATCH] perf: draw each frame of a looping html5 page once --- src/components/canvas/players/html5-player.ts | 45 ++++++++++++++----- tests/html5-player.test.ts | 33 ++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index b1158fe6..a0dce90c 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -35,6 +35,13 @@ const CAPTURE_SLICE_MS = 12; const CAPTURE_MEMORY_BUDGET_BYTES = 256 * 1024 * 1024; const CAPTURE_PROGRESS_STEPS = 20; const BAKE_SCALES = [0.25, 0.5, 0.75, 1]; +// A remembered frame holds its body markup, so a window bounds the memory. 300 frames covers a 10 s loop at 30 fps. +const REPEAT_WINDOW_FRAMES = 300; +const ANIMATION_OVERRIDE = ``; + +function foreignObjectSvg(width: number, height: number, styles: string, body: string): string { + return `${styles}${ANIMATION_OVERRIDE}${body}`; +} function forceLayout(el: HTMLElement): void { el.getBoundingClientRect(); @@ -460,9 +467,9 @@ export class Html5Player extends Player { const framesInFlight = captureFramesInFlight(navigator.hardwareConcurrency, Math.trunc(W * scale) * Math.trunc(H * scale) * 4); const blobs: Blob[] = []; const pending: Promise[] = []; - // The SVG is a frame's whole input, so an identical one draws identical bytes and shares the previous frame's Blob. - let lastSvg = ""; - let lastFrame: Promise | null = null; + // The SVG is a frame's whole input, so a frame that repeats a recent one draws identical bytes and shares its Blob. + const recent = new Map>(); + let recentStyles = ""; let issued = 0; let progressStep = 0; let sliceStart = -Infinity; @@ -476,14 +483,23 @@ export class Html5Player extends Player { } this.seekHarness(issued / fps); forceLayout(this.iframe.contentDocument.body); - const svg = this.captureIframeAsForeignObjectSvg(W, H); - if (!lastFrame || svg !== lastSvg) { - lastSvg = svg; - lastFrame = foreignObjectSvgToWebp(svg, W, H, scale).then(bytes => new Blob([bytes as BlobPart], { type: "image/webp" })); + const { styles, body } = this.serialiseIframe(W, H); + if (styles !== recentStyles) { + recent.clear(); + recentStyles = styles; + } + let drawn = recent.get(body); + if (!drawn) { + drawn = foreignObjectSvgToWebp(foreignObjectSvg(W, H, styles, body), W, H, scale).then( + bytes => new Blob([bytes as BlobPart], { type: "image/webp" }) + ); // Marks a queued frame's failure handled; awaiting it rethrows. - lastFrame.catch(() => undefined); + drawn.catch(() => undefined); + // A Map iterates in insertion order, so its first key is the oldest frame. + if (recent.size >= REPEAT_WINDOW_FRAMES) recent.delete(recent.keys().next().value!); + recent.set(body, drawn); } - pending.push(lastFrame); + pending.push(drawn); issued += 1; } else { const frame = await pending.shift()!; @@ -552,13 +568,19 @@ export class Html5Player extends Player { } private captureIframeAsForeignObjectSvg(width: number, height: number, iframe: HTMLIFrameElement | null = this.iframe): string { + const { styles, body } = this.serialiseIframe(width, height, iframe); + return foreignObjectSvg(width, height, styles, body); + } + + // The styles carry the page's inlined fonts and images, hundreds of KB that rarely change between frames, so a + // bake compares them apart from the body. + private serialiseIframe(width: number, height: number, iframe: HTMLIFrameElement | null = this.iframe): { styles: string; body: string } { if (!iframe?.contentDocument) throw new Error("iframe not ready"); const doc = iframe.contentDocument; const serializer = new XMLSerializer(); const styles = Array.from(doc.querySelectorAll("style")) .map(el => serializer.serializeToString(el)) .join(""); - const animationOverride = ``; const bodyClone = doc.body.cloneNode(true) as HTMLElement; // Scripts never run in an SVG image, and their source can hold characters XML forbids, which fail the frame. bodyClone.querySelectorAll("script").forEach(script => script.remove()); @@ -586,8 +608,7 @@ export class Html5Player extends Player { } const existingStyle = bodyClone.getAttribute("style") ?? ""; bodyClone.setAttribute("style", `width:${width}px;height:${height}px;margin:0;overflow:hidden;${existingStyle}`); - const bodyXml = serializer.serializeToString(bodyClone); - return `${styles}${animationOverride}${bodyXml}`; + return { styles, body: serializer.serializeToString(bodyClone) }; } private async getDecodedFrame(idx: number): Promise { diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index 94696d51..cbf4f072 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -753,6 +753,39 @@ it("draws a frame only when the page changed, reusing the last frame's blob", as expect(await frameMarkup(frames[59])).toContain('data-t="0.5"'); }); +it("draws each frame of a looping page once, reusing it every time the loop comes round", async () => { + const { harness, iframe } = createStillPlayer(); // 2 s at 30 fps: 60 frames + jest.mocked(harness.seekHarness).mockImplementation((seconds, win = iframe.contentWindow) => { + const { body } = win!.document; + body.textContent = `phase=${Math.round(seconds * 30) % 15}`; + }); + const toBlob = jest.mocked(HTMLCanvasElement.prototype.toBlob); + toBlob.mockClear(); + + const frames = (await harness.runCapture())!; + + expect(toBlob).toHaveBeenCalledTimes(15); + expect(frames[45]).toBe(frames[0]); + expect(frames[59]).toBe(frames[14]); + expect(await frameMarkup(frames[59])).toContain(">phase=14<"); +}); + +it("shares no frame between pages that differ only in their styles", async () => { + const { harness, iframe } = createStillPlayer(); // 2 s at 30 fps: 60 frames + jest.mocked(harness.seekHarness).mockImplementation((seconds, win = iframe.contentWindow) => { + const doc = win!.document; + const style = doc.querySelector("style") ?? doc.head.appendChild(doc.createElement("style")); + style.textContent = seconds >= 2 / 3 && seconds < 4 / 3 ? ".b{}" : ".a{}"; + doc.body.textContent = "same"; + }); + + const frames = (await harness.runCapture())!; + + expect(await frameMarkup(frames[30])).toContain(".b{}"); + expect(await frameMarkup(frames[59])).toContain(".a{}"); + expect(frames[59]).not.toBe(frames[30]); +}); + it.each([ [undefined, 4], [2, 4],