From d3e1447d5296fc93ef7370a51eed4241d69d2a8a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 8 Oct 2026 16:08:50 +1100 Subject: [PATCH] fix: capture html5 clips from a freshly loaded document --- src/components/canvas/players/html5-player.ts | 7 +++++ tests/html5-player.test.ts | 30 ++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index 37732243..a0db87cf 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -313,6 +313,13 @@ export class Html5Player extends Player { return cached.pngs; } + // Live editing seeks this iframe, and a seeked animation can leave state + // a fresh page lacks (GSAP writes start values inline once a tween has + // rendered). The backend captures from a fresh page, so capture does too. + this.iframe.srcdoc = composeHtml5IframeSrcdoc(this.asset); + await waitForIframeLoad(this.iframe, undefined, true); + if (stale() || !this.iframe?.contentDocument) return null; + try { await this.iframe.contentDocument.fonts.ready; } catch { diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts index dfac1419..7e51f201 100644 --- a/tests/html5-player.test.ts +++ b/tests/html5-player.test.ts @@ -16,7 +16,12 @@ jest.mock("@canvas/players/player", () => ({ PlayerType: { Html5: "html5" } })); jest.mock("@shotstack/shotstack-canvas", () => ({ - composeHtml5IframeSrcdoc: () => "Updated" + composeHtml5IframeSrcdoc: () => "Updated", + computeHtml5FrameCount: () => ({ frameCount: 1 }) +})); +jest.mock("@canvas/players/html5-cache", () => ({ + html5CacheGet: async () => null, + html5CachePut: async () => undefined })); jest.mock("@canvas/players/placeholder-graphic", () => ({ createPlaceholderGraphic: () => ({ kind: "failed", destroy: jest.fn() }), @@ -31,6 +36,7 @@ type PlayerHarness = { contentContainer: { children: unknown[] }; captureIframeAsForeignObjectSvg(width: number, height: number): string; captureFrames(): Promise; + seekHarness(seconds: number): void; hashAsset(): Promise; beginCapture(): void; syncIframePosition(): void; @@ -105,6 +111,28 @@ it("ignores an older iframe timeout while newer content is loading", async () => expect(harness.contentContainer.children).toEqual([expect.objectContaining({ kind: "loading" })]); }); +it("captures from a freshly loaded document rather than the live-edited one", async () => { + const { player, harness, iframe } = createPlayer(); + Object.assign(player, { getLength: () => 1 }); + iframe.srcdoc = "Scrubbed"; + const steps: string[] = []; + iframe.addEventListener("load", () => steps.push("load")); + jest.spyOn(harness, "seekHarness").mockImplementation(() => { + steps.push(`seek ${iframe.srcdoc}`); + throw new Error("stop after first seek"); + }); + + const capture = harness.captureFrames(); + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + iframe.dispatchEvent(new Event("load")); + + await expect(capture).rejects.toThrow("stop after first seek"); + expect(steps.at(-1)).toBe("seek Updated"); + expect(steps.indexOf("load")).toBeLessThan(steps.length - 1); +}); + it("captures inline SVG backgrounds and CSS metacharacters as valid XML without changing their contents", () => { const { harness, iframe } = createPlayer(); const doc = iframe.contentDocument!;