From 9abf65ae5e7db90463fcdce3b548a061657814c9 Mon Sep 17 00:00:00 2001 From: terencecho <3916587+terencecho@users.noreply.github.com> Date: Thu, 14 May 2026 16:34:38 -0700 Subject: [PATCH] fix(player): correct playback rate for direct-timeline and audio-clock paths (#849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- packages/core/src/runtime/clock.test.ts | 24 +++++++++++++++++++++++ packages/core/src/runtime/clock.ts | 5 ++++- packages/core/src/runtime/init.ts | 3 +++ packages/player/src/hyperframes-player.ts | 1 + packages/player/src/timeline-adapters.ts | 2 ++ 5 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/clock.test.ts b/packages/core/src/runtime/clock.test.ts index 8b274052c..c5529068b 100644 --- a/packages/core/src/runtime/clock.test.ts +++ b/packages/core/src/runtime/clock.test.ts @@ -327,5 +327,29 @@ describe("TransportClock", () => { audioEl.currentTime = 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); + }); }); }); diff --git a/packages/core/src/runtime/clock.ts b/packages/core/src/runtime/clock.ts index 6ad67d1e4..6cd42096e 100644 --- a/packages/core/src/runtime/clock.ts +++ b/packages/core/src/runtime/clock.ts @@ -48,7 +48,10 @@ export class TransportClock { } else { const { el, compositionStart, mediaStart } = this._audioSource; 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) { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 5d79b76f8..7ecc5a594 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1586,6 +1586,7 @@ export function initSandboxRuntimeModular(): void { onSetPlaybackRate: (rate) => { applyPlaybackRate(rate); if (state.transportClock) state.transportClock.setRate(state.playbackRate); + webAudio.setRate(state.playbackRate); }, onTick: () => { if (state.tornDown || !clock.isPlaying()) return; @@ -1927,6 +1928,7 @@ export function initSandboxRuntimeModular(): void { clock.now(), vol * state.bridgeVolume, gen, + state.playbackRate, ); }); } @@ -1997,6 +1999,7 @@ export function initSandboxRuntimeModular(): void { player.setPlaybackRate = (rate: number) => { applyPlaybackRate(rate); clock.setRate(state.playbackRate); + webAudio.setRate(state.playbackRate); }; // Sync clock duration from any captured timeline diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 79097e2e5..ecaf7a363 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -177,6 +177,7 @@ class HyperframesPlayer extends HTMLElement { const rate = parseFloat(val || "1"); this._media.updatePlaybackRate(rate); this._sendControl("set-playback-rate", { playbackRate: rate }); + this._directTimelineAdapter?.timeScale?.(rate); this.controlsApi?.updateSpeed(rate); this.dispatchEvent(new Event("ratechange")); break; diff --git a/packages/player/src/timeline-adapters.ts b/packages/player/src/timeline-adapters.ts index 25ac6c8a2..5d19eaab7 100644 --- a/packages/player/src/timeline-adapters.ts +++ b/packages/player/src/timeline-adapters.ts @@ -24,6 +24,8 @@ export interface DirectTimelineAdapter { seek: (timeInSeconds: number) => unknown; play: () => 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 =