mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* 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>
218 lines
7.2 KiB
TypeScript
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(),
|
|
};
|
|
}
|