Skip to content

Commit 36fc6f0

Browse files
committed
fix: keep a frame's embedded images when Safari draws an html5 clip
1 parent d15997e commit 36fc6f0

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,33 @@ const CAPTURE_PROGRESS_STEPS = 20;
4040
const BAKE_SCALES = [0.25, 0.5, 0.75, 1];
4141
// A remembered frame holds its body markup, so a window bounds the memory. 300 frames covers a 10 s loop at 30 fps.
4242
const REPEAT_WINDOW_FRAMES = 300;
43+
// Part of every bake's cache key, so raising it redraws every cached bake.
44+
const FRAME_DRAWING_VERSION = 1;
4345
const ANIMATION_OVERRIDE = `<style>*,*::before,*::after{animation:none!important;transition:none!important}</style>`;
4446

47+
// A base64 bitmap in a frame's markup: a CSS url(), an <img> source, or a canvas painted as a background.
48+
const NESTED_BITMAP = /data:image\/(?!svg)[\w.+-]+;base64,[A-Za-z0-9+/=]+/g;
49+
50+
// WebKit lays an SVG image out as offscreen, so a large bitmap inside one decodes asynchronously and a draw before
51+
// the decode lands leaves it out. Nothing signals that decode, but an <img decoding="sync"> decodes synchronously, so
52+
// one per bitmap, painted ahead of the page, has every bitmap ready within the same draw. opacity:0 would skip its paint.
53+
// Other engines decode in time and would pay for the extra decodes on every frame. Every iOS browser runs WebKit and
54+
// reports Apple as its vendor.
4555
function foreignObjectSvg(width: number, height: number, styles: string, body: string): string {
46-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${ANIMATION_OVERRIDE}${body}</foreignObject></svg>`;
56+
const decoders =
57+
navigator.vendor === "Apple Computer, Inc."
58+
? [...new Set(`${styles}${body}`.match(NESTED_BITMAP))]
59+
.map(
60+
src =>
61+
`<img xmlns="http://www.w3.org/1999/xhtml" decoding="sync" src="${src}" style="position:absolute;left:0;top:0;width:1px;height:1px;filter:opacity(0)!important"/>`
62+
)
63+
.join("")
64+
: "";
65+
return (
66+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
67+
`${decoders && `<foreignObject width="1" height="1">${decoders}</foreignObject>`}` +
68+
`<foreignObject width="${width}" height="${height}">${styles}${ANIMATION_OVERRIDE}${body}</foreignObject></svg>`
69+
);
4770
}
4871

4972
function forceLayout(el: HTMLElement): void {
@@ -207,7 +230,7 @@ export class Html5Player extends Player {
207230
width: this.renderedWidth,
208231
height: this.renderedHeight
209232
});
210-
return `${content}:${frameCount}@${captureFps}`;
233+
return `${content}:${frameCount}@${captureFps}:v${FRAME_DRAWING_VERSION}`;
211234
}
212235

213236
// Canvas pixels per clip pixel: a bake at this scale or above looks as sharp as a full-size one.

‎tests/html5-player.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,40 @@ it("paints canvas pixels into the capture as the canvas's own background", () =>
382382
expect(canvas.getAttribute("style")).toBe("border: 4px solid gold;");
383383
});
384384

385+
it("decodes every image a capture embeds before the page paints in WebKit, without drawing it", () => {
386+
const { harness, iframe } = createPlayer();
387+
const doc = iframe.contentDocument!;
388+
jest.spyOn(navigator, "vendor", "get").mockReturnValue("Apple Computer, Inc.");
389+
const photo = "data:image/jpeg;base64,/9j/4AAQ";
390+
const style = doc.createElement("style");
391+
style.textContent = `.hero{background:url(${photo}) center/cover} .again{background-image:url('${photo}')} .grain{background:url('data:image/svg+xml;utf8,<svg/>')}`;
392+
doc.head.appendChild(style);
393+
const canvas = doc.createElement("canvas");
394+
doc.body.replaceChildren(Object.assign(doc.createElement("div"), { className: "hero" }), canvas);
395+
jest.spyOn(Object.getPrototypeOf(canvas) as HTMLCanvasElement, "toDataURL").mockReturnValue("data:image/png;base64,AAAA");
396+
397+
const parsed = new DOMParser().parseFromString(harness.captureIframeAsForeignObjectSvg(1080, 1920), "image/svg+xml");
398+
const [decoders, page] = Array.from(parsed.documentElement.children);
399+
const images = Array.from(decoders.querySelectorAll("img"));
400+
401+
expect(parsed.querySelector("parsererror")).toBeNull();
402+
expect(images.map(img => img.getAttribute("src"))).toEqual([photo, "data:image/png;base64,AAAA"]);
403+
expect(images.every(img => img.getAttribute("decoding") === "sync" && img.getAttribute("style")!.includes("filter:opacity(0)"))).toBe(true);
404+
expect(page.querySelector(".hero")).not.toBeNull();
405+
});
406+
407+
it("leaves embedded images to the page outside WebKit", () => {
408+
const { harness, iframe } = createPlayer();
409+
const doc = iframe.contentDocument!;
410+
jest.spyOn(navigator, "vendor", "get").mockReturnValue("Google Inc.");
411+
doc.body.replaceChildren(Object.assign(doc.createElement("img"), { src: "data:image/jpeg;base64,/9j/4AAQ" }));
412+
413+
const parsed = new DOMParser().parseFromString(harness.captureIframeAsForeignObjectSvg(1080, 1920), "image/svg+xml");
414+
415+
expect(parsed.documentElement.children).toHaveLength(1);
416+
expect(parsed.querySelectorAll("img")).toHaveLength(1);
417+
});
418+
385419
it("keeps a failed capture inside its canvas layer and retries only after the asset changes", async () => {
386420
const { player, harness, iframe, events, edit } = createPlayer();
387421
jest.spyOn(console, "warn").mockImplementation(() => {});

0 commit comments

Comments
 (0)