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 ``;
}
@@ -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" })]);
+});