mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(player): rescale on cross-origin timeline ready, guard warn spam (#1840)
onRuntimeTimelineReady (the cross-origin ready signal for signed CDN composition URLs) never called _rescale(), leaving the iframe unscaled and untransformed if the runtime's stage-size postMessage was ever skipped. Also adds a one-shot diagnostic warning when a rescale keeps no-oping after ready, latched so a legitimately hidden/zero-size player doesn't spam the console.
This commit is contained in:
@@ -1989,6 +1989,37 @@ describe("HyperframesPlayer runtime ready handshake", () => {
|
||||
expect(player.paused).toBe(false);
|
||||
expect(findControlCalls("play")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rescales the iframe on cross-origin timeline readiness even without a stage-size message", () => {
|
||||
// Regression: the runtime's postTimeline() only sends `stage-size` when it
|
||||
// can resolve the root's data-width/data-height at that instant — a race
|
||||
// that can lose on first paint. onRuntimeTimelineReady must not depend on
|
||||
// stage-size having arrived, or the iframe is left unscaled/untranslated
|
||||
// (rendered pinned to the top-left instead of centered and fit).
|
||||
Object.defineProperty(player, "offsetWidth", { value: 400, configurable: true });
|
||||
Object.defineProperty(player, "offsetHeight", { value: 300, configurable: true });
|
||||
|
||||
expect(player.iframe.style.transform).toBe("");
|
||||
|
||||
player._onMessage(timelineMessage(120));
|
||||
|
||||
expect(player.iframe.style.transform).not.toBe("");
|
||||
expect(player.iframe.style.transform).toContain("translate(-50%, -50%)");
|
||||
});
|
||||
|
||||
it("warns at most once per instance when rescale keeps no-oping after ready", () => {
|
||||
// A player that stays zero-size after ready (hidden tab, collapsed
|
||||
// carousel card) keeps getting rescale attempts from every subsequent
|
||||
// width/height attribute change and ResizeObserver tick. The diagnostic
|
||||
// warning must not spam the console once per instance.
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
|
||||
player._onMessage(timelineMessage(120)); // first no-op after ready
|
||||
player.setAttribute("width", "800"); // still zero-size — would no-op again
|
||||
player.setAttribute("height", "450"); // ditto
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => {
|
||||
|
||||
@@ -86,6 +86,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
private _volume = 1;
|
||||
private _compositionWidth = 1920;
|
||||
private _compositionHeight = 1080;
|
||||
private _rescaleWarned = false;
|
||||
private _directTimelineAdapter: DirectTimelineAdapter | null = null;
|
||||
private _directTimelineClock: DirectTimelineClock;
|
||||
private _parentTickRaf: number | null = null;
|
||||
@@ -667,6 +668,10 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this._ready = true;
|
||||
this.controlsApi?.updateTime(this._currentTime, duration);
|
||||
this.dispatchEvent(new CustomEvent("ready", { detail: { duration } }));
|
||||
// stage-size may not have arrived yet (race in the runtime's postTimeline
|
||||
// resolving the root's data-width/data-height on first paint) — rescale
|
||||
// here too so cross-origin compositions never stay unscaled/untransformed.
|
||||
this._rescale();
|
||||
|
||||
const doc = this._getSameOriginIframeDocument();
|
||||
if (doc) this._media.setupFromIframe(doc);
|
||||
@@ -698,7 +703,28 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
|
||||
private _rescale() {
|
||||
scaleIframeToFit(this, this.iframe, this._compositionWidth, this._compositionHeight);
|
||||
const applied = scaleIframeToFit(
|
||||
this,
|
||||
this.iframe,
|
||||
this._compositionWidth,
|
||||
this._compositionHeight,
|
||||
);
|
||||
// A no-op before "ready" is expected (element not painted yet). A no-op
|
||||
// once ready means the composition is stuck unscaled/untransformed —
|
||||
// pinned to the iframe's default top-left position — with no evidence of
|
||||
// why in the field. Surface it once (not on every ResizeObserver tick —
|
||||
// a legitimately hidden/zero-sized player, e.g. a collapsed tab or
|
||||
// off-screen carousel card, would otherwise spam the console forever).
|
||||
if (!applied && this._ready && !this._rescaleWarned) {
|
||||
this._rescaleWarned = true;
|
||||
console.warn("[hyperframes-player] rescale no-op after ready — zero-size player element", {
|
||||
src: this.getAttribute("src"),
|
||||
offsetWidth: this.offsetWidth,
|
||||
offsetHeight: this.offsetHeight,
|
||||
compositionWidth: this._compositionWidth,
|
||||
compositionHeight: this._compositionHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _onIframeLoad() {
|
||||
|
||||
@@ -57,18 +57,21 @@ export function createCompositionIframe(): {
|
||||
/**
|
||||
* Scale the iframe so the composition fits inside the player element while
|
||||
* preserving aspect ratio. No-ops when the player has no painted size yet.
|
||||
* Returns whether the transform was actually applied, so callers can tell a
|
||||
* real no-op (still 0×0) apart from a successful rescale.
|
||||
*/
|
||||
export function scaleIframeToFit(
|
||||
playerElement: HTMLElement,
|
||||
iframe: HTMLIFrameElement,
|
||||
compositionWidth: number,
|
||||
compositionHeight: number,
|
||||
): void {
|
||||
): boolean {
|
||||
const w = playerElement.offsetWidth;
|
||||
const h = playerElement.offsetHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
if (w === 0 || h === 0) return false;
|
||||
const scale = Math.min(w / compositionWidth, h / compositionHeight);
|
||||
iframe.style.width = `${compositionWidth}px`;
|
||||
iframe.style.height = `${compositionHeight}px`;
|
||||
iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user