diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index bfc5bf52..37732243 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -65,11 +65,12 @@ async function foreignObjectSvgToWebp(svg: string, width: number, height: number * Drives whether the live iframe or the captured frame[] is the visible source. * * editing — iframe live, no sprite. Default for new content. - * capturing — iframe parked, loading placeholder shown. Triggered by first Play. + * capturing — iframe parked, loading placeholder shown. * playback — captured sprite mounted, iframe parked. Pixi-side filters apply. * stale — captured frame[] invalidated by content change; iframe re-shown. + * failed — iframe parked, placeholder stays in the clip's canvas layer. */ -type Html5Mode = "editing" | "capturing" | "playback" | "stale"; +type Html5Mode = "editing" | "capturing" | "playback" | "stale" | "failed"; export class Html5Player extends Player { private static captureChain: Promise = Promise.resolve(); @@ -90,6 +91,7 @@ export class Html5Player extends Player { private staticSprite: pixi.Sprite | null = null; private hasTriggeredCapture: boolean = false; private loadingGraphic: pixi.Container | null = null; + private fallbackGraphic: pixi.Graphics | null = null; private loadingSetProgress: ((fraction: number) => void) | null = null; private captureFramesDone: number = 0; private captureFramesTotal: number = 0; @@ -250,6 +252,7 @@ export class Html5Player extends Player { this.playbackSprite = null; } this.removeLoadingGraphic(); + this.removeFallbackGraphic(); this.disposeCapturedFrames(); this.mode = "stale"; } @@ -261,6 +264,14 @@ export class Html5Player extends Player { this.emitCaptureStarted(); } + private transitionToFailed(): void { + if (this.disposed) return; + this.mode = "failed"; + this.parkIframe(); + this.removeLoadingGraphic(); + this.createFallbackGraphic(); + } + // ─── capture pipeline ────────────────────────────────────────────────────── private async captureFrames(): Promise { @@ -359,15 +370,15 @@ export class Html5Player extends Player { private captureIframeAsForeignObjectSvg(width: number, height: number): string { if (!this.iframe?.contentDocument) throw new Error("iframe not ready"); const doc = this.iframe.contentDocument; + const serializer = new XMLSerializer(); const styles = Array.from(doc.querySelectorAll("style")) - .map(el => ``) + .map(el => serializer.serializeToString(el)) .join(""); const animationOverride = ``; const bodyClone = doc.body.cloneNode(true) as HTMLElement; - bodyClone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); const existingStyle = bodyClone.getAttribute("style") ?? ""; bodyClone.setAttribute("style", `width:${width}px;height:${height}px;margin:0;overflow:hidden;${existingStyle}`); - const bodyXml = new XMLSerializer().serializeToString(bodyClone); + const bodyXml = serializer.serializeToString(bodyClone); return `${styles}${animationOverride}${bodyXml}`; } @@ -417,7 +428,7 @@ export class Html5Player extends Player { */ public override async prepareStaticRender(): Promise { try { - if (this.disposed || !this.iframe) return; + if (this.disposed || !this.iframe || this.mode === "failed") return; const frames = await this.captureFrames(); if (this.disposed || !frames || frames.length === 0) return; const idx = Math.min(Math.max(0, Math.floor(this.getPlaybackTime() * this.captureFps)), frames.length - 1); @@ -462,10 +473,13 @@ export class Html5Player extends Player { this.iframe.srcdoc = composeHtml5IframeSrcdoc(this.asset); try { await waitForIframeLoad(this.iframe, undefined, true); + if (this.disposed || this.contentHash !== newHash) return; this.beginCapture(); } catch (err) { + if (this.disposed || this.contentHash !== newHash) return; console.warn("[Html5Player] reload iframe load failed:", err); this.emitCaptureFailed(err, "static-placeholder"); + this.transitionToFailed(); } } @@ -479,15 +493,24 @@ export class Html5Player extends Player { } private createFallbackGraphic(): void { + if (this.fallbackGraphic) return; const width = this.clipConfiguration.width || this.edit.size.width; const height = this.clipConfiguration.height || this.edit.size.height; const graphics = createPlaceholderGraphic(width, height); + this.fallbackGraphic = graphics; this.renderedWidth = width; this.renderedHeight = height; this.contentContainer.addChild(graphics); this.configureKeyframes(); } + private removeFallbackGraphic(): void { + if (!this.fallbackGraphic) return; + this.contentContainer.removeChild(this.fallbackGraphic); + this.fallbackGraphic.destroy(); + this.fallbackGraphic = null; + } + public override update(deltaTime: number, elapsed: number): void { super.update(deltaTime, elapsed); this.triggerCaptureIfNeeded(); @@ -497,7 +520,7 @@ export class Html5Player extends Player { if (this.loadingSetProgress && this.captureFramesTotal > 0) { this.loadingSetProgress(this.captureFramesDone / this.captureFramesTotal); } - } else if (this.iframe) { + } else if (this.iframe && this.mode !== "failed") { this.syncIframePosition(); if (this.isActive()) this.seekHarness(this.getPlaybackTime()); } @@ -507,32 +530,34 @@ export class Html5Player extends Player { if (this.disposed || !this.iframe) return; if (this.mode === "capturing" || this.mode === "playback") return; if (this.captureInFlight) return; + const hashAtStart = this.contentHash; if (this.capturedFrames && this.capturedHash === this.contentHash) { this.transitionToPlayback().catch(err => { + if (this.disposed || this.contentHash !== hashAtStart) return; console.warn("[Html5Player] transitionToPlayback failed:", err); - this.transitionToEditing(); + this.emitCaptureFailed(err, "static-placeholder"); + this.transitionToFailed(); }); return; } this.transitionToCapturing(); - const hashAtStart = this.contentHash; this.captureFrames() .then(async frames => { - if (this.disposed) return; - const fresh = !!frames && frames.length > 0 && this.capturedHash === hashAtStart && this.contentHash === hashAtStart; + if (this.disposed || this.contentHash !== hashAtStart) return; + const fresh = !!frames && frames.length > 0 && this.capturedHash === hashAtStart; if (!fresh) { - // Stale/empty result — fall back to the live iframe instead of stranding the loader. - if (this.mode === "capturing") this.transitionToEditing(); + if (this.mode === "capturing") this.transitionToFailed(); return; } await this.transitionToPlayback(); }) .catch(err => { + if (this.disposed || this.contentHash !== hashAtStart) return; console.warn("[Html5Player] capture failed:", err); - this.emitCaptureFailed(err, "live-iframe"); - if (this.mode === "capturing") this.transitionToEditing(); + this.emitCaptureFailed(err, "static-placeholder"); + if (this.mode === "capturing") this.transitionToFailed(); }); } @@ -637,6 +662,7 @@ export class Html5Player extends Player { public override dispose(): void { this.disposed = true; + this.removeFallbackGraphic(); super.dispose(); this.iframe?.remove(); this.iframe = null; diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts new file mode 100644 index 00000000..dfac1419 --- /dev/null +++ b/tests/html5-player.test.ts @@ -0,0 +1,160 @@ +/** @jest-environment jsdom */ + +import { Html5Player } from "@canvas/players/html5-player"; +import type { Edit } from "@core/edit-session"; +import { EditEvent } from "@core/events/edit-events"; +import type { ResolvedClip } from "@schemas"; + +// Canvas rendering is unavailable in jsdom; the HTML5 DOM and state transitions stay real. +jest.mock("pixi.js", () => ({})); +jest.mock("@canvas/players/player", () => ({ + Player: class { + update() { + return this; + } + }, + PlayerType: { Html5: "html5" } +})); +jest.mock("@shotstack/shotstack-canvas", () => ({ + composeHtml5IframeSrcdoc: () => "Updated" +})); +jest.mock("@canvas/players/placeholder-graphic", () => ({ + createPlaceholderGraphic: () => ({ kind: "failed", destroy: jest.fn() }), + createCaptureLoadingGraphic: () => ({ container: { kind: "loading", destroy: jest.fn() }, setProgress: jest.fn() }) +})); + +type PlayerHarness = { + iframe: HTMLIFrameElement; + contentHash: string; + capturedHash: string | null; + capturedFrames: Blob[] | null; + contentContainer: { children: unknown[] }; + captureIframeAsForeignObjectSvg(width: number, height: number): string; + captureFrames(): Promise; + hashAsset(): Promise; + beginCapture(): void; + syncIframePosition(): void; + transitionToPlayback(): Promise; +}; + +function createPlayer() { + const events = { emit: jest.fn() }; + const edit = { size: { width: 1080, height: 1920 }, isPlaying: false, getInternalEvents: () => events }; + const clip = { id: "background", start: 0, length: 60, asset: { type: "html5", html: "
Background
" } }; + const player = new Html5Player(edit as unknown as Edit, clip as ResolvedClip); + const iframe = document.createElement("iframe"); + document.body.appendChild(iframe); + const children: unknown[] = []; + Object.assign(player, { + edit, + clipConfiguration: clip, + clipId: clip.id, + iframe, + contentHash: "original", + contentContainer: { + children, + addChild: (child: unknown) => children.push(child), + removeChild: (child: unknown) => children.splice(children.indexOf(child), 1) + }, + configureKeyframes: jest.fn(), + isActive: () => true, + getPlaybackTime: () => 0 + }); + return { player, harness: player as unknown as PlayerHarness, iframe, events, edit }; +} + +afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + document.body.replaceChildren(); +}); + +it("ignores a capture decode failure after its content has been replaced", async () => { + const { harness, events } = createPlayer(); + jest.spyOn(console, "warn").mockImplementation(() => {}); + harness.capturedFrames = [new Blob()]; + harness.capturedHash = harness.contentHash; + jest.spyOn(harness, "transitionToPlayback").mockRejectedValue(new Error("decode failed")); + + harness.beginCapture(); + harness.contentHash = "updated"; + await Promise.resolve(); + + expect(events.emit).not.toHaveBeenCalledWith(EditEvent.ClipCaptureFailed, expect.anything()); + expect(harness.contentContainer.children).toHaveLength(0); +}); + +it("ignores an older iframe timeout while newer content is loading", async () => { + jest.useFakeTimers(); + const { player, harness, iframe, events } = createPlayer(); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(harness, "hashAsset").mockResolvedValueOnce("older").mockResolvedValueOnce("newer"); + jest.spyOn(harness, "captureFrames").mockImplementation(() => new Promise(() => {})); + + const older = player.reloadAsset(); + await Promise.resolve(); + jest.advanceTimersByTime(5000); + const newer = player.reloadAsset(); + await Promise.resolve(); + jest.advanceTimersByTime(5000); + await older; + iframe.dispatchEvent(new Event("load")); + await newer; + + expect(events.emit).not.toHaveBeenCalledWith(EditEvent.ClipCaptureFailed, expect.anything()); + expect(harness.contentContainer.children).toEqual([expect.objectContaining({ kind: "loading" })]); +}); + +it("captures inline SVG backgrounds and CSS metacharacters as valid XML without changing their contents", () => { + const { harness, iframe } = createPlayer(); + const doc = iframe.contentDocument!; + const css = `.grain{background-image:url('data:image/svg+xml;utf8,')} .label::before{content:'A & B < C ]]> D'}`; + const style = doc.createElement("style"); + style.textContent = css; + doc.head.appendChild(style); + doc.body.innerHTML = "
A & B
"; + + const svg = harness.captureIframeAsForeignObjectSvg(1080, 1920); + const parsed = new DOMParser().parseFromString(svg, "image/svg+xml"); + + expect(parsed.querySelector("parsererror")).toBeNull(); + expect(parsed.querySelector("style")?.textContent).toBe(css); + expect(parsed.querySelector("body")?.namespaceURI).toBe("http://www.w3.org/1999/xhtml"); + expect(parsed.querySelector(".grain")?.textContent).toBe("A & B"); + expect(doc.head.querySelector("style")?.textContent).toBe(css); +}); + +it("keeps a failed capture inside its canvas layer and retries only after the asset changes", async () => { + const { player, harness, iframe, events, edit } = createPlayer(); + jest.spyOn(console, "warn").mockImplementation(() => {}); + const capture = jest.spyOn(harness, "captureFrames").mockRejectedValue(new Error("The source image cannot be decoded.")); + const sync = jest.spyOn(harness, "syncIframePosition").mockImplementation(() => { + iframe.style.left = "0px"; + }); + + harness.beginCapture(); + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + player.update(0, 0); + edit.isPlaying = true; + player.update(0, 0); + player.update(0, 0); + await player.prepareStaticRender(); + + expect(iframe.style.left).toBe("-10000px"); + expect(sync).not.toHaveBeenCalled(); + expect(capture).toHaveBeenCalledTimes(1); + expect(harness.contentContainer.children).toEqual([expect.objectContaining({ kind: "failed" })]); + expect(events.emit).toHaveBeenCalledWith(EditEvent.ClipCaptureFailed, expect.objectContaining({ fallback: "static-placeholder" })); + + jest.spyOn(harness, "hashAsset").mockResolvedValue("updated"); + capture.mockImplementation(() => new Promise(() => {})); + const reload = player.reloadAsset(); + await Promise.resolve(); + iframe.dispatchEvent(new Event("load")); + await reload; + + expect(capture).toHaveBeenCalledTimes(2); + expect(harness.contentContainer.children).toEqual([expect.objectContaining({ kind: "loading" })]); +});