Files
hyperframes/packages/studio/src/player/lib/playbackAdapter.ts
T
Vance IngallsandClaude Fable 5 7a99ccec6d fix(core): honor root data-duration when GSAP timeline ends short (#1378)
* fix(core): honor root data-duration when GSAP timeline ends short

The authored-duration floor only counted child composition clips, never
the root element's own data-duration. A composition whose GSAP timeline
ended even 0.1s short of its declared data-duration reported the shorter
timeline length from player.getDuration() — and the studio's adapter
selection (docDuration <= adapterDur) then silently rejected the
audio-capable runtime player, downgrading preview playback to the
seek-scrubbing adapter, which never starts media elements or WebAudio.
Result: total audio silence with zero errors anywhere.

- include the root's declared data-duration in
  resolveAuthoredCompositionDurationFloorSeconds, making data-duration
  the source of truth for playable length (per the documented contract)
- console.warn in the studio when playback falls back to the
  seek-driven adapter, since the downgrade loses audio invisibly

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(studio): release static-seek adapter on native win, warn once on downgrade

Review findings on the previous commit, all in the static-seek fallback
path of useTimelinePlayer.getAdapter:

- A cached static-seek adapter was never paused when adapter selection
  later resolved a native adapter (the early returns bypass the fallback
  branch entirely), leaving its private rAF loop seeking the player while
  the native transport also drives it. The core data-duration fix makes
  this switch path much more common. releaseStaticSeekCache() now runs
  at every native-adapter return and at unmount.
- The downgrade warning fired on every cache miss — and the cache key can
  never hold for __timelines compositions because wrapTimeline() returns
  a fresh object per call, so it fired every rAF tick. It now warns once
  per downgrade streak (re-armed when a native adapter takes over).
- The warning interpolated adapterDur (the native __player duration,
  0 when absent) instead of the selected adapter's duration, and used a
  one-off "[hyperframes-studio]" prefix instead of the file's
  "[useTimelinePlayer]" convention.

The fallback cache logic moved to playbackAdapter.ts (with unit tests for
warn-once, cache identity, and pause-on-replace/release), which also
keeps useTimelinePlayer.ts inside the studio 600-line limit. Also
corrected a stale "no DOM reads" comment on the runtime transport tick —
the duration floor has always queried the DOM per call, and now also
reads the root's declared data-duration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:01:39 -07:00

218 lines
7.2 KiB
TypeScript

/**
* Playback adapter utilities: factory for the static-seek adapter used when a
* composition exposes only a `renderSeek` / `seek` API (no native play/pause
* support), plus a thin wrapper that normalises GSAP-style `TimelineLike`
* objects to the `PlaybackAdapter` interface.
*/
import type {
PlaybackAdapter,
RuntimePlaybackAdapter,
StaticSeekPlaybackClock,
TimelineLike,
} from "./playbackTypes";
// ---------------------------------------------------------------------------
// Pure numeric helpers
// ---------------------------------------------------------------------------
export function isFinitePositive(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
function clampTime(time: number, duration: number): number {
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
const safeTime = Math.max(0, Number.isFinite(time) ? time : 0);
return safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
}
export function getAdapterDuration(adapter: PlaybackAdapter | null | undefined): number {
if (!adapter) return 0;
try {
const duration = Number(adapter.getDuration());
return isFinitePositive(duration) ? duration : 0;
} catch {
return 0;
}
}
// ---------------------------------------------------------------------------
// Clock factory
// ---------------------------------------------------------------------------
export function getDefaultStaticSeekPlaybackClock(win: Window): StaticSeekPlaybackClock {
return {
now: () => win.performance.now(),
requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
};
}
// ---------------------------------------------------------------------------
// Static-seek adapter
// ---------------------------------------------------------------------------
/**
* Wraps a render-only player (exposes `renderSeek`/`seek` but no native
* play/pause) and drives playback via `requestAnimationFrame`.
*/
export function createStaticSeekPlaybackAdapter(
player: Pick<RuntimePlaybackAdapter, "getTime"> &
Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>,
duration: number,
clock: StaticSeekPlaybackClock,
getPlaybackRate: () => number = () => 1,
): PlaybackAdapter {
const safeDuration = Math.max(0, Number.isFinite(duration) ? duration : 0);
let currentTime = clampTime(Number(player.getTime?.() ?? 0), safeDuration);
let playing = false;
let rafId = 0;
let playStartTime = currentTime;
let playStartNow = clock.now();
const renderSeek = (time: number) => {
currentTime = clampTime(time, safeDuration);
if (typeof player.renderSeek === "function") {
player.renderSeek(currentTime);
return;
}
player.seek?.(currentTime);
};
const stopTicker = () => {
if (rafId) {
clock.cancelAnimationFrame(rafId);
rafId = 0;
}
};
const tick: FrameRequestCallback = (now) => {
if (!playing) return;
const playbackRate = Math.max(0.1, Number(getPlaybackRate()) || 1);
const elapsed = ((now - playStartNow) / 1000) * playbackRate;
renderSeek(playStartTime + elapsed);
if (currentTime >= safeDuration) {
playing = false;
rafId = 0;
return;
}
rafId = clock.requestAnimationFrame(tick);
};
return {
play: () => {
if (playing || safeDuration <= 0) return;
if (currentTime >= safeDuration) renderSeek(0);
playing = true;
playStartTime = currentTime;
playStartNow = clock.now();
stopTicker();
rafId = clock.requestAnimationFrame(tick);
},
pause: () => {
playing = false;
stopTicker();
},
seek: (time, options) => {
renderSeek(time);
if (options?.keepPlaying) {
if (playing) {
playStartTime = currentTime;
playStartNow = clock.now();
}
return;
}
// Default seek aligns with wrapTimeline: stop the RAF ticker so the
// adapter's `playing` flag matches the public seek contract instead of
// silently driving renderSeek in the background.
playing = false;
stopTicker();
},
getTime: () => currentTime,
getDuration: () => safeDuration,
isPlaying: () => playing,
};
}
// ---------------------------------------------------------------------------
// Static-seek fallback cache
// ---------------------------------------------------------------------------
export type StaticSeekCacheEntry = {
player: RuntimePlaybackAdapter | PlaybackAdapter;
duration: number;
adapter: PlaybackAdapter;
};
type StaticSeekCacheRef = { current: StaticSeekCacheEntry | null };
type WarnedRef = { current: boolean };
/**
* Pause and drop the cached static-seek adapter. Must be called whenever
* adapter selection switches to a native adapter — a cached static-seek
* adapter that was mid-play keeps its private rAF loop seeking the player
* forever otherwise, fighting the native transport. Also re-arms the
* downgrade warning so a later re-downgrade is surfaced again.
*/
export function releaseStaticSeekCache(cache: StaticSeekCacheRef, warned: WarnedRef): void {
cache.current?.adapter.pause();
cache.current = null;
warned.current = false;
}
/**
* Resolve (with caching) the seek-driven fallback adapter. Warns once per
* downgrade streak: seek-driven playback never starts media elements or
* WebAudio, so without the warning the downgrade silently loses audio.
*/
export function resolveStaticSeekFallback(opts: {
cache: StaticSeekCacheRef;
warned: WarnedRef;
bestAdapter: RuntimePlaybackAdapter | PlaybackAdapter;
effectiveDuration: number;
docDuration: number;
clock: StaticSeekPlaybackClock;
getPlaybackRate: () => number;
}): PlaybackAdapter {
const { cache, warned, bestAdapter, effectiveDuration, docDuration } = opts;
const cached = cache.current;
if (cached?.player === bestAdapter && cached.duration === effectiveDuration) {
return cached.adapter;
}
cached?.adapter.pause();
if (!warned.current) {
warned.current = true;
console.warn(
`[useTimelinePlayer] Selected adapter duration (${getAdapterDuration(bestAdapter)}s) does not cover the document duration (${docDuration}s); falling back to seek-driven playback, which never starts media elements or WebAudio. Audio will not play in preview — extend the GSAP timeline to cover the declared data-duration.`,
);
}
const adapter = createStaticSeekPlaybackAdapter(
bestAdapter,
effectiveDuration,
opts.clock,
opts.getPlaybackRate,
);
cache.current = { player: bestAdapter, duration: effectiveDuration, adapter };
return adapter;
}
// ---------------------------------------------------------------------------
// GSAP timeline wrapper
// ---------------------------------------------------------------------------
export function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
return {
play: () => tl.play(),
pause: () => tl.pause(),
seek: (t, options) => {
const shouldPause = !options?.keepPlaying;
if (shouldPause) tl.pause();
tl.seek(t);
if (shouldPause) tl.pause();
},
getTime: () => tl.time(),
getDuration: () => tl.duration(),
isPlaying: () => tl.isActive(),
};
}