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:
Vance Ingalls
2026-07-01 14:36:48 -07:00
committed by GitHub
parent 24edb15095
commit e5ec4cf532
3 changed files with 63 additions and 3 deletions
@@ -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", () => {
+27 -1
View File
@@ -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() {
+5 -2
View File
@@ -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;
}