diff --git a/package.json b/package.json
index 605e58a..87b5124 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts
index bea35fd..ea46e0b 100644
--- a/src/components/canvas/players/html5-player.ts
+++ b/src/components/canvas/players/html5-player.ts
@@ -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";
@@ -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();
@@ -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;
@@ -379,12 +394,13 @@ export class Html5Player extends Player {
const styles = Array.from(doc.querySelectorAll("style"))
.map(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);
- return ``;
+ return ``;
}
private async getDecodedFrame(idx: number): Promise {
@@ -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 {
@@ -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;
diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts
index 17bf1fc..88accb0 100644
--- a/src/core/player-reconciler.ts
+++ b/src/core/player-reconciler.ts
@@ -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(() => {