mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(player): correct playback rate for direct-timeline and audio-clock paths (#849)
## Summary - **Direct-timeline path** (GSAP compositions with `window.__timelines`): The player drives these via `DirectTimelineAdapter`, bypassing postMessage entirely. Rate changes sent `set-playback-rate` to the iframe but had no receiver — GSAP's `timeScale()` was never called. Fix: add optional `timeScale?` to `DirectTimelineAdapter` and call `this._directTimelineAdapter?.timeScale?.(rate)` in `attributeChangedCallback`. GSAP timelines expose `timeScale` natively, no composition changes required. - **Audio-clock path** (compositions with audio): Three bugs caused `TransportClock` to always run at 1x when an audio element or WebAudio context drove the clock: 1. `schedulePlayback` was called without the `playbackRate` arg (defaulted to 1). 2. `onSetPlaybackRate` and `player.setPlaybackRate` didn't call `webAudio.setRate()`. 3. `TransportClock.attachAudioSource` divided by `this._rate` instead of `el.playbackRate`, cancelling the rate multiplier. - Adds 2 regression tests to `clock.test.ts` covering the corrected audio-clock formula. ## Test plan - [ ] Unit tests: `bun run --cwd packages/core test` — 861/861 pass - [ ] Browser verification (Playwright headless, GSAP direct-timeline composition): - 1x speed → ratio 0.972 ✓ - 2x speed → ratio 1.965 ✓ - 0.5x speed → ratio 0.490 ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -327,5 +327,29 @@ describe("TransportClock", () => {
|
|||||||
audioEl.currentTime = 3.5;
|
audioEl.currentTime = 3.5;
|
||||||
expect(clock.now()).toBe(3.5);
|
expect(clock.now()).toBe(3.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("at 2x rate, audio-derived time advances at 2x (not 1x)", () => {
|
||||||
|
// el.playbackRate=2 means el.currentTime advances at 2x wall-clock speed.
|
||||||
|
// With rate=2, composition time should also advance at 2x — so
|
||||||
|
// composition_time = (el.currentTime - mediaStart) + compositionStart.
|
||||||
|
// The old bug divided by rate instead, yielding 1x speed.
|
||||||
|
const { clock } = createClock({ rate: 2, duration: 20 });
|
||||||
|
const audioEl = { currentTime: 4, paused: false, playbackRate: 2 } as HTMLMediaElement;
|
||||||
|
clock.play();
|
||||||
|
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
||||||
|
// After 1 wall-clock second at 2x: el.currentTime=4, composition should be at 4.
|
||||||
|
expect(clock.now()).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composition time is correct when el.playbackRate differs from clock rate", () => {
|
||||||
|
// General formula: wall_elapsed = (el.currentTime - mediaStart) / el.playbackRate
|
||||||
|
// composition_time = compositionStart + wall_elapsed * clockRate
|
||||||
|
const { clock } = createClock({ rate: 2, duration: 20 });
|
||||||
|
// Audio at 1x, clock at 2x: after 1s wall, el.currentTime=1, comp should be 2.
|
||||||
|
const audioEl = { currentTime: 1, paused: false, playbackRate: 1 } as HTMLMediaElement;
|
||||||
|
clock.play();
|
||||||
|
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
||||||
|
expect(clock.now()).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export class TransportClock {
|
|||||||
} else {
|
} else {
|
||||||
const { el, compositionStart, mediaStart } = this._audioSource;
|
const { el, compositionStart, mediaStart } = this._audioSource;
|
||||||
if (!el.paused && Number.isFinite(el.currentTime)) {
|
if (!el.paused && Number.isFinite(el.currentTime)) {
|
||||||
audioTime = (el.currentTime - mediaStart) / this._rate + compositionStart;
|
audioTime =
|
||||||
|
((el.currentTime - mediaStart) / (el.playbackRate > 0 ? el.playbackRate : 1)) *
|
||||||
|
this._rate +
|
||||||
|
compositionStart;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (audioTime !== null) {
|
if (audioTime !== null) {
|
||||||
|
|||||||
@@ -1586,6 +1586,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
onSetPlaybackRate: (rate) => {
|
onSetPlaybackRate: (rate) => {
|
||||||
applyPlaybackRate(rate);
|
applyPlaybackRate(rate);
|
||||||
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
||||||
|
webAudio.setRate(state.playbackRate);
|
||||||
},
|
},
|
||||||
onTick: () => {
|
onTick: () => {
|
||||||
if (state.tornDown || !clock.isPlaying()) return;
|
if (state.tornDown || !clock.isPlaying()) return;
|
||||||
@@ -1927,6 +1928,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
clock.now(),
|
clock.now(),
|
||||||
vol * state.bridgeVolume,
|
vol * state.bridgeVolume,
|
||||||
gen,
|
gen,
|
||||||
|
state.playbackRate,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1997,6 +1999,7 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
player.setPlaybackRate = (rate: number) => {
|
player.setPlaybackRate = (rate: number) => {
|
||||||
applyPlaybackRate(rate);
|
applyPlaybackRate(rate);
|
||||||
clock.setRate(state.playbackRate);
|
clock.setRate(state.playbackRate);
|
||||||
|
webAudio.setRate(state.playbackRate);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync clock duration from any captured timeline
|
// Sync clock duration from any captured timeline
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ class HyperframesPlayer extends HTMLElement {
|
|||||||
const rate = parseFloat(val || "1");
|
const rate = parseFloat(val || "1");
|
||||||
this._media.updatePlaybackRate(rate);
|
this._media.updatePlaybackRate(rate);
|
||||||
this._sendControl("set-playback-rate", { playbackRate: rate });
|
this._sendControl("set-playback-rate", { playbackRate: rate });
|
||||||
|
this._directTimelineAdapter?.timeScale?.(rate);
|
||||||
this.controlsApi?.updateSpeed(rate);
|
this.controlsApi?.updateSpeed(rate);
|
||||||
this.dispatchEvent(new Event("ratechange"));
|
this.dispatchEvent(new Event("ratechange"));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface DirectTimelineAdapter {
|
|||||||
seek: (timeInSeconds: number) => unknown;
|
seek: (timeInSeconds: number) => unknown;
|
||||||
play: () => unknown;
|
play: () => unknown;
|
||||||
pause: () => unknown;
|
pause: () => unknown;
|
||||||
|
/** Optional: set playback rate (e.g. GSAP's timeScale). Called when the player's playbackRate changes. */
|
||||||
|
timeScale?: (scale: number) => unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlaybackDurationAdapter =
|
export type PlaybackDurationAdapter =
|
||||||
|
|||||||
Reference in New Issue
Block a user