Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
},
"dependencies": {
"@shotstack/schemas": "1.11.0",
"@shotstack/shotstack-canvas": "^2.7.2",
"@shotstack/shotstack-canvas": "^2.7.3",
"howler": "^2.2.4",
"mediabunny": "^1.11.2",
"opentype.js": "^1.3.4",
Expand Down
57 changes: 49 additions & 8 deletions src/components/canvas/players/html5-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import type { Edit } from "@core/edit-session";
import { EditEvent } from "@core/events/edit-events";
import { type Size } from "@layouts/geometry";
import type { ResolvedClip } from "@schemas";
import { Html5AssetSchema, composeHtml5IframeSrcdoc, type Html5Asset } from "@shotstack/shotstack-canvas";
import {
Html5AssetSchema,
composeHtml5IframeSrcdoc,
computeHtml5FrameCount,
detectHtml5DurationWithRetry,
type Html5Asset
} from "@shotstack/shotstack-canvas";
import * as pixi from "pixi.js";

import { computeHtml5CacheKey, html5CacheGet, html5CachePut } from "./html5-cache";
Expand Down Expand Up @@ -214,6 +220,7 @@ export class Html5Player extends Player {
}
await this.mountIframe(validation.data);
this.configureKeyframes();
this.prewarmCapture();
} catch (error) {
console.error("Failed to render html5 asset:", error instanceof Error ? `${error.message}\n${error.stack}` : error);
this.createFallbackGraphic();
Expand Down Expand Up @@ -327,12 +334,20 @@ export class Html5Player extends Player {
await yieldFrame();
if (stale()) return null;

const detectedSeconds = this.detectAnimationDuration();
const clipLengthSeconds = this.getLength();
const hasJs = !!this.asset.js?.trim();
const isStatic = detectedSeconds === null && !hasJs;
const detectedDurationMs = await detectHtml5DurationWithRetry(() => {
const seconds = this.detectAnimationDuration();
return seconds === null ? null : seconds * 1000;
}, stale);
if (stale()) return null;

const { frameCount } = computeHtml5FrameCount({
detectedDurationMs,
clipLengthSeconds: this.getLength(),
jsContent: this.asset.js,
cssContent: this.asset.css,
fps: this.captureFps
});
const fps = this.captureFps;
const frameCount = isStatic ? 1 : Math.max(1, Math.ceil(Math.min(detectedSeconds ?? clipLengthSeconds, clipLengthSeconds) * fps));
const W = this.renderedWidth;
const H = this.renderedHeight;
this.captureFramesTotal = frameCount;
Expand Down Expand Up @@ -379,12 +394,13 @@ export class Html5Player extends Player {
const styles = Array.from(doc.querySelectorAll("style"))
.map(el => `<style>${el.textContent ?? ""}</style>`)
.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);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${bodyXml}</foreignObject></svg>`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${animationOverride}${bodyXml}</foreignObject></svg>`;
}

private async getDecodedFrame(idx: number): Promise<pixi.Texture | null> {
Expand Down Expand Up @@ -439,11 +455,11 @@ export class Html5Player extends Player {
this.iframe.srcdoc = composeHtml5IframeSrcdoc(this.asset);
try {
await waitForIframeLoad(this.iframe, undefined, true);
this.prewarmCapture();
} catch (err) {
console.warn("[Html5Player] reload iframe load failed:", err);
this.emitCaptureFailed(err, "static-placeholder");
}
// Stay in stale mode (iframe live) until the user plays again.
}

private disposeCapturedFrames(): void {
Expand Down Expand Up @@ -480,11 +496,36 @@ export class Html5Player extends Player {
}
}

/**
* Run capture in the background without changing UI mode.
*/
private prewarmCapture(): void {
if (this.disposed || !this.iframe) return;
if (this.capturedFrames || this.captureInFlight) return;
this.captureFrames().catch(err => {
console.warn("[Html5Player] prewarm capture failed:", err);
});
}

private triggerCaptureIfNeeded(): void {
if (this.hasTriggeredCapture) return;
if (!this.iframe || !this.edit.isPlaying) return;
if (this.mode !== "editing" && this.mode !== "stale") return;

if (this.capturedFrames && this.capturedHash === this.contentHash) {
const hashAtTrigger = this.contentHash;
this.hasTriggeredCapture = true;
this.transitionToPlayback()
.catch(err => {
console.warn("[Html5Player] transitionToPlayback failed:", err);
this.transitionToEditing();
})
.finally(() => {
if (this.contentHash !== hashAtTrigger) this.hasTriggeredCapture = false;
});
return;
}

this.hasTriggeredCapture = true;
this.transitionToCapturing();
const hashAtTrigger = this.contentHash;
Expand Down
19 changes: 13 additions & 6 deletions src/core/player-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,18 +332,25 @@ export class PlayerReconciler {
}

/**
* Update player's asset and trigger reload if src changed.
* Update player's asset and trigger reload if the asset content changed.
*/
private updateAsset(player: Player, newAsset: unknown): void {
const oldSrc = (player.clipConfiguration.asset as { src?: string })?.src;
const newSrc = (newAsset as { src?: string })?.src;
const oldAsset = player.clipConfiguration.asset;
const assetType = (newAsset as { type?: string })?.type;

// Update the asset
// eslint-disable-next-line no-param-reassign -- Intentional player state update
player.clipConfiguration.asset = newAsset as ResolvedClip["asset"];

// If src changed, trigger async reload
if (oldSrc !== newSrc && player.reloadAsset) {
let needsReload: boolean;
if (assetType === "html5") {
needsReload = JSON.stringify(oldAsset) !== JSON.stringify(newAsset);
} else {
const oldSrc = (oldAsset as { src?: string })?.src;
const newSrc = (newAsset as { src?: string })?.src;
needsReload = oldSrc !== newSrc;
}

if (needsReload && player.reloadAsset) {
player
.reloadAsset()
.then(() => {
Expand Down
Loading