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>
This commit is contained in:
Vance Ingalls
2026-06-12 11:01:39 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 740f244c03
commit 7a99ccec6d
5 changed files with 228 additions and 31 deletions
@@ -22,12 +22,14 @@ export {
shouldIgnorePlaybackShortcutTarget,
} from "../lib/playbackShortcuts";
import type { PlaybackAdapter, RuntimePlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
import {
getAdapterDuration,
wrapTimeline,
createStaticSeekPlaybackAdapter,
getDefaultStaticSeekPlaybackClock,
releaseStaticSeekCache,
resolveStaticSeekFallback,
type StaticSeekCacheEntry,
} from "../lib/playbackAdapter";
import {
readTimelineDurationFromDocument,
@@ -53,11 +55,8 @@ export function useTimelinePlayer() {
const shuttleSpeedIndexRef = useRef(0);
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
const lastTimelineMessageRef = useRef<number>(0);
const staticSeekAdapterRef = useRef<{
player: RuntimePlaybackAdapter | PlaybackAdapter;
duration: number;
adapter: PlaybackAdapter;
} | null>(null);
const staticSeekAdapterRef = useRef<StaticSeekCacheEntry | null>(null);
const staticSeekWarnedRef = useRef(false);
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
usePlayerStore.getState();
@@ -141,6 +140,7 @@ export function useTimelinePlayer() {
const adapterDur = getAdapterDuration(playerAdapter);
if (adapterDur > 0 && docDuration <= adapterDur) {
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
return playerAdapter;
}
@@ -148,24 +148,28 @@ export function useTimelinePlayer() {
if (win.__timeline) {
const adapter = wrapTimeline(win.__timeline);
const dur = getAdapterDuration(adapter);
if (dur > 0 && docDuration <= dur) return adapter;
if (dur > 0 && docDuration <= dur) {
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
return adapter;
}
if (dur > 0) timelineAdapter ??= adapter;
}
if (win.__timelines) {
const keys = Object.keys(win.__timelines);
if (keys.length > 0) {
// Resolve the root composition id from the DOM — the outermost
// `[data-composition-id]` element is the master. Without this,
// Object.keys() order would let a sub-composition's timeline
// hijack play/pause/seek and the duration readout.
// Resolve the root composition id from the DOM — the outermost [data-composition-id]
// is the master; otherwise Object.keys() order lets a sub-composition hijack transport.
const rootId = iframe?.contentDocument
?.querySelector("[data-composition-id]")
?.getAttribute("data-composition-id");
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
const adapter = wrapTimeline(win.__timelines[key]);
const dur = getAdapterDuration(adapter);
if (dur > 0 && docDuration <= dur) return adapter;
if (dur > 0 && docDuration <= dur) {
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
return adapter;
}
if (dur > 0) timelineAdapter ??= adapter;
}
}
@@ -184,23 +188,15 @@ export function useTimelinePlayer() {
effectiveDuration > 0 &&
("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function")
) {
const cached = staticSeekAdapterRef.current;
if (cached?.player === bestAdapter && cached.duration === effectiveDuration) {
return cached.adapter;
}
cached?.adapter.pause();
const adapter = createStaticSeekPlaybackAdapter(
return resolveStaticSeekFallback({
cache: staticSeekAdapterRef,
warned: staticSeekWarnedRef,
bestAdapter,
effectiveDuration,
getDefaultStaticSeekPlaybackClock(win),
() => usePlayerStore.getState().playbackRate,
);
staticSeekAdapterRef.current = {
player: bestAdapter,
duration: effectiveDuration,
adapter,
};
return adapter;
docDuration,
clock: getDefaultStaticSeekPlaybackClock(win),
getPlaybackRate: () => usePlayerStore.getState().playbackRate,
});
}
return bestAdapter;
@@ -561,6 +557,7 @@ export function useTimelinePlayer() {
document.removeEventListener("visibilitychange", handleVisibilityChange);
stopRAFLoop();
stopReverseLoop();
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
};
});
@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { createStaticSeekPlaybackAdapter, wrapTimeline } from "./playbackAdapter";
import {
createStaticSeekPlaybackAdapter,
wrapTimeline,
resolveStaticSeekFallback,
releaseStaticSeekCache,
type StaticSeekCacheEntry,
} from "./playbackAdapter";
import type {
RuntimePlaybackAdapter,
StaticSeekPlaybackClock,
@@ -211,3 +217,82 @@ describe("createStaticSeekPlaybackAdapter seek keepPlaying option", () => {
expect(adapter.isPlaying()).toBe(false);
});
});
describe("static-seek fallback cache (resolveStaticSeekFallback / releaseStaticSeekCache)", () => {
function makeClock(): StaticSeekPlaybackClock {
return {
now: () => 0,
requestAnimationFrame: () => 0,
cancelAnimationFrame: () => {},
};
}
function makePlayer() {
return { getTime: () => 0, renderSeek: vi.fn() };
}
function resolve(
cache: { current: StaticSeekCacheEntry | null },
warned: { current: boolean },
player: ReturnType<typeof makePlayer>,
duration: number,
) {
return resolveStaticSeekFallback({
cache,
warned,
bestAdapter: player as unknown as RuntimePlaybackAdapter,
effectiveDuration: duration,
docDuration: duration,
clock: makeClock(),
getPlaybackRate: () => 1,
});
}
it("warns once per downgrade streak and re-arms after release", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
const warned = { current: false };
const player = makePlayer();
resolve(cache, warned, player, 10);
resolve(cache, warned, player, 11); // cache miss (new duration) — must not warn again
expect(warn).toHaveBeenCalledTimes(1);
releaseStaticSeekCache(cache, warned);
resolve(cache, warned, player, 12);
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
it("returns the cached adapter for the same player and duration", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
const warned = { current: false };
const player = makePlayer();
const first = resolve(cache, warned, player, 10);
const second = resolve(cache, warned, player, 10);
expect(second).toBe(first);
warn.mockRestore();
});
it("pauses the replaced adapter on cache miss and the cached adapter on release", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
const warned = { current: false };
const player = makePlayer();
const first = resolve(cache, warned, player, 10);
first.play();
expect(first.isPlaying()).toBe(true);
const second = resolve(cache, warned, player, 20);
expect(first.isPlaying()).toBe(false);
second.play();
expect(second.isPlaying()).toBe(true);
releaseStaticSeekCache(cache, warned);
expect(second.isPlaying()).toBe(false);
expect(cache.current).toBeNull();
vi.restoreAllMocks();
});
});
@@ -134,6 +134,68 @@ export function createStaticSeekPlaybackAdapter(
};
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------