Files
hyperframes/packages/player/src/timeline-adapters.ts
T
terencecho 9abf65ae5e 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)
2026-05-14 16:34:38 -07:00

53 lines
1.9 KiB
TypeScript

/**
* Types and type-guards for the two playback adapter paths the player supports:
*
* - `RuntimeDurationAdapter` — the HyperFrames runtime exposes `window.__player`
* with a `getDuration()` method. This is the standard path for compositions
* served through the runtime bridge.
*
* - `DirectTimelineAdapter` — same-origin standalone compositions can expose
* their GSAP master timeline at `window.__timelines` without installing the
* full runtime. The player drives play/pause/seek directly against the
* timeline object, bypassing the postMessage bridge.
*
* `PlaybackDurationAdapter` is the discriminated union the probe interval
* returns after deciding which path is available.
*/
export interface RuntimeDurationAdapter {
getDuration: () => number;
}
export interface DirectTimelineAdapter {
duration: () => number;
time: () => number;
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 =
| { kind: "runtime"; getDuration: () => number }
| { kind: "direct-timeline"; timeline: DirectTimelineAdapter; getDuration: () => number };
export function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isRuntimeDurationAdapter(value: unknown): value is RuntimeDurationAdapter {
return isObjectRecord(value) && typeof value.getDuration === "function";
}
export function isDirectTimelineAdapter(value: unknown): value is DirectTimelineAdapter {
return (
isObjectRecord(value) &&
typeof value.duration === "function" &&
typeof value.time === "function" &&
typeof value.seek === "function" &&
typeof value.play === "function" &&
typeof value.pause === "function"
);
}