Skip to content

Commit baaecce

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

2 files changed

Lines changed: 41 additions & 2 deletions

File tree

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,28 @@ 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.
4553
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>`;
54+
const decoders = [...new Set(`${styles}${body}`.match(NESTED_BITMAP))]
55+
.map(
56+
src =>
57+
`<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"/>`
58+
)
59+
.join("");
60+
return (
61+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
62+
`${decoders && `<foreignObject width="1" height="1">${decoders}</foreignObject>`}` +
63+
`<foreignObject width="${width}" height="${height}">${styles}${ANIMATION_OVERRIDE}${body}</foreignObject></svg>`
64+
);
4765
}
4866

4967
function forceLayout(el: HTMLElement): void {
@@ -207,7 +225,7 @@ export class Html5Player extends Player {
207225
width: this.renderedWidth,
208226
height: this.renderedHeight
209227
});
210-
return `${content}:${frameCount}@${captureFps}`;
228+
return `${content}:${frameCount}@${captureFps}:v${FRAME_DRAWING_VERSION}`;
211229
}
212230

213231
// 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,27 @@ 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, without drawing it", () => {
386+
const { harness, iframe } = createPlayer();
387+
const doc = iframe.contentDocument!;
388+
const photo = "data:image/jpeg;base64,/9j/4AAQ";
389+
const style = doc.createElement("style");
390+
style.textContent = `.hero{background:url(${photo}) center/cover} .again{background-image:url('${photo}')} .grain{background:url('data:image/svg+xml;utf8,<svg/>')}`;
391+
doc.head.appendChild(style);
392+
const canvas = doc.createElement("canvas");
393+
doc.body.replaceChildren(Object.assign(doc.createElement("div"), { className: "hero" }), canvas);
394+
jest.spyOn(Object.getPrototypeOf(canvas) as HTMLCanvasElement, "toDataURL").mockReturnValue("data:image/png;base64,AAAA");
395+
396+
const parsed = new DOMParser().parseFromString(harness.captureIframeAsForeignObjectSvg(1080, 1920), "image/svg+xml");
397+
const [decoders, page] = Array.from(parsed.documentElement.children);
398+
const images = Array.from(decoders.querySelectorAll("img"));
399+
400+
expect(parsed.querySelector("parsererror")).toBeNull();
401+
expect(images.map(img => img.getAttribute("src"))).toEqual([photo, "data:image/png;base64,AAAA"]);
402+
expect(images.every(img => img.getAttribute("decoding") === "sync" && img.getAttribute("style")!.includes("filter:opacity(0)"))).toBe(true);
403+
expect(page.querySelector(".hero")).not.toBeNull();
404+
});
405+
385406
it("keeps a failed capture inside its canvas layer and retries only after the asset changes", async () => {
386407
const { player, harness, iframe, events, edit } = createPlayer();
387408
jest.spyOn(console, "warn").mockImplementation(() => {});

0 commit comments

Comments
 (0)