Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 41 additions & 15 deletions src/components/canvas/players/html5-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> = Promise.resolve();
Expand All @@ -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;
Expand Down Expand Up @@ -250,6 +252,7 @@ export class Html5Player extends Player {
this.playbackSprite = null;
}
this.removeLoadingGraphic();
this.removeFallbackGraphic();
this.disposeCapturedFrames();
this.mode = "stale";
}
Expand All @@ -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<Blob[] | null> {
Expand Down Expand Up @@ -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 => `<style>${el.textContent ?? ""}</style>`)
.map(el => serializer.serializeToString(el))
.join("");
const animationOverride = `<style>*,*::before,*::after{animation:none!important;transition:none!important}</style>`;
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 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${animationOverride}${bodyXml}</foreignObject></svg>`;
}

Expand Down Expand Up @@ -417,7 +428,7 @@ export class Html5Player extends Player {
*/
public override async prepareStaticRender(): Promise<void> {
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);
Expand Down Expand Up @@ -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();
}
}

Expand All @@ -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();
Expand All @@ -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());
}
Expand All @@ -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();
});
}

Expand Down Expand Up @@ -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;
Expand Down
160 changes: 160 additions & 0 deletions tests/html5-player.test.ts
Original file line number Diff line number Diff line change
@@ -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: () => "<html><body>Updated</body></html>"
}));
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<Blob[] | null>;
hashAsset(): Promise<string>;
beginCapture(): void;
syncIframePosition(): void;
transitionToPlayback(): Promise<void>;
};

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: "<div>Background</div>" } };
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,<svg xmlns=%22http://www.w3.org/2000/svg%22><rect/></svg>')} .label::before{content:'A & B < C ]]> D'}`;
const style = doc.createElement("style");
style.textContent = css;
doc.head.appendChild(style);
doc.body.innerHTML = "<div class='grain'>A &amp; B</div>";

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" })]);
});
Loading