fix(player): clamp playbackRate to runtime [0.1, 5] range (#1120)

This commit is contained in:
Carlos Alcaraz Gregor
2026-05-29 12:30:12 -04:00
committed by GitHub
parent 43c56ee476
commit 62475b7649
2 changed files with 136 additions and 3 deletions
@@ -1496,3 +1496,121 @@ describe("HyperframesPlayer volume and mute", () => {
expect(zeroVolumeHtml).not.toBe(mutedHtml);
});
});
// ── Playback rate ──
describe("HyperframesPlayer playback rate", () => {
let player: HTMLElement & {
playbackRate: number;
iframeElement: HTMLIFrameElement;
};
let mockAudio: {
preload: string;
src: string;
muted: boolean;
volume: number;
playbackRate: number;
currentTime: number;
load: ReturnType<typeof vi.fn>;
play: ReturnType<typeof vi.fn>;
pause: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
await import("./hyperframes-player.js");
mockAudio = {
preload: "",
src: "",
muted: false,
volume: 1,
playbackRate: 1,
currentTime: 0,
load: vi.fn(),
play: vi.fn().mockResolvedValue(undefined),
pause: vi.fn(),
};
vi.spyOn(globalThis, "Audio").mockImplementation(
() => mockAudio as unknown as HTMLAudioElement,
);
player = document.createElement("hyperframes-player") as typeof player;
});
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
it("defaults playbackRate to 1", () => {
document.body.appendChild(player);
expect(player.playbackRate).toBe(1);
});
it("clamps playbackRate to [0.1, 5]", () => {
document.body.appendChild(player);
player.playbackRate = 100;
expect(player.playbackRate).toBe(5);
// Assert the reflected attribute directly so the setter clamp is pinned
// independently of the getter clamp.
expect(player.getAttribute("playback-rate")).toBe("5");
player.playbackRate = 0.01;
expect(player.playbackRate).toBe(0.1);
expect(player.getAttribute("playback-rate")).toBe("0.1");
});
it("falls back to 1 for a non-positive or non-finite playbackRate", () => {
document.body.appendChild(player);
player.playbackRate = -2;
expect(player.playbackRate).toBe(1);
expect(player.getAttribute("playback-rate")).toBe("1");
player.playbackRate = 0;
expect(player.playbackRate).toBe(1);
player.playbackRate = NaN;
expect(player.playbackRate).toBe(1);
});
it("propagates the clamped rate to parent media, never an out-of-range value", () => {
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);
player.setAttribute("playback-rate", "50");
expect(mockAudio.playbackRate).toBe(5);
player.setAttribute("playback-rate", "0.01");
expect(mockAudio.playbackRate).toBe(0.1);
});
it("sends the clamped rate as a set-playback-rate control to the iframe", () => {
document.body.appendChild(player);
const postMessageSpy = vi.fn();
Object.defineProperty(player.iframeElement, "contentWindow", {
value: { postMessage: postMessageSpy },
configurable: true,
});
player.setAttribute("playback-rate", "50");
expect(postMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({
source: "hf-parent",
type: "control",
action: "set-playback-rate",
playbackRate: 5,
}),
"*",
);
});
it("clamps an out-of-range value set directly via the attribute on read", () => {
document.body.appendChild(player);
player.setAttribute("playback-rate", "999");
expect(player.playbackRate).toBe(5);
});
});
+18 -3
View File
@@ -18,6 +18,21 @@ import { ShaderLoaderState } from "./shader-loader-state.js";
import { PLAYER_STYLES } from "./styles.js";
import { type DirectTimelineAdapter } from "./timeline-adapters.js";
// Playback-rate bounds mirror the runtime clamp in
// packages/core/src/runtime/init.ts (applyPlaybackRate) and media.ts so the
// player accepts the same range as the in-iframe runtime: an out-of-range rate
// would otherwise drive the parent-proxied <audio> outside the bounds the
// timeline itself respects. Clamping here also shields the native
// HTMLMediaElement.playbackRate setter, which throws for extreme values in
// production browsers.
const MIN_PLAYBACK_RATE = 0.1;
const MAX_PLAYBACK_RATE = 5;
function clampPlaybackRate(rate: number): number {
if (!Number.isFinite(rate) || rate <= 0) return 1;
return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
}
class HyperframesPlayer extends HTMLElement {
static get observedAttributes() {
return [
@@ -174,7 +189,7 @@ class HyperframesPlayer extends HTMLElement {
this.posterEl = setupPoster(this.shadow, val, this.posterEl);
break;
case "playback-rate": {
const rate = parseFloat(val || "1");
const rate = clampPlaybackRate(parseFloat(val || "1"));
this._media.updatePlaybackRate(rate);
this._sendControl("set-playback-rate", { playbackRate: rate });
this._directTimelineAdapter?.timeScale?.(rate);
@@ -296,10 +311,10 @@ class HyperframesPlayer extends HTMLElement {
}
get playbackRate() {
return parseFloat(this.getAttribute("playback-rate") || "1");
return clampPlaybackRate(parseFloat(this.getAttribute("playback-rate") || "1"));
}
set playbackRate(r: number) {
this.setAttribute("playback-rate", String(r));
this.setAttribute("playback-rate", String(clampPlaybackRate(r)));
}
get shaderCaptureScale() {