Skip to content

Commit f12f49f

Browse files
vanceingallsclaude
andcommitted
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>
1 parent 1ee2680 commit f12f49f

4 files changed

Lines changed: 176 additions & 30 deletions

File tree

packages/core/src/runtime/init.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,8 +1969,11 @@ export function initSandboxRuntimeModular(): void {
19691969
}
19701970

19711971
// Keep clock duration in sync with the resolved timeline duration.
1972-
// Cheap (no DOM reads) and catches async timeline rebinds that happen
1973-
// outside the 60-tick branch (metadata hydration, deferred setTimeout).
1972+
// Catches async timeline rebinds that happen outside the 60-tick
1973+
// branch (metadata hydration, deferred setTimeout). Note: this reads
1974+
// the DOM each tick (duration floors query authored windows + the
1975+
// root's declared data-duration), which also keeps live edits to
1976+
// data-duration in the studio reflected without a rebind.
19741977
if (state.capturedTimeline) {
19751978
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
19761979
if (dur > 0) clock.setDuration(dur);

packages/studio/src/player/hooks/useTimelinePlayer.ts

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ export {
2222
shouldIgnorePlaybackShortcutTarget,
2323
} from "../lib/playbackShortcuts";
2424

25-
import type { PlaybackAdapter, RuntimePlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
25+
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
2626
import {
2727
getAdapterDuration,
2828
wrapTimeline,
29-
createStaticSeekPlaybackAdapter,
3029
getDefaultStaticSeekPlaybackClock,
30+
releaseStaticSeekCache,
31+
resolveStaticSeekFallback,
32+
type StaticSeekCacheEntry,
3133
} from "../lib/playbackAdapter";
3234
import {
3335
readTimelineDurationFromDocument,
@@ -53,11 +55,8 @@ export function useTimelinePlayer() {
5355
const shuttleSpeedIndexRef = useRef(0);
5456
const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
5557
const lastTimelineMessageRef = useRef<number>(0);
56-
const staticSeekAdapterRef = useRef<{
57-
player: RuntimePlaybackAdapter | PlaybackAdapter;
58-
duration: number;
59-
adapter: PlaybackAdapter;
60-
} | null>(null);
58+
const staticSeekAdapterRef = useRef<StaticSeekCacheEntry | null>(null);
59+
const staticSeekWarnedRef = useRef(false);
6160

6261
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
6362
usePlayerStore.getState();
@@ -141,14 +140,18 @@ export function useTimelinePlayer() {
141140
const adapterDur = getAdapterDuration(playerAdapter);
142141

143142
if (adapterDur > 0 && docDuration <= adapterDur) {
143+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
144144
return playerAdapter;
145145
}
146146

147147
let timelineAdapter: PlaybackAdapter | null = null;
148148
if (win.__timeline) {
149149
const adapter = wrapTimeline(win.__timeline);
150150
const dur = getAdapterDuration(adapter);
151-
if (dur > 0 && docDuration <= dur) return adapter;
151+
if (dur > 0 && docDuration <= dur) {
152+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
153+
return adapter;
154+
}
152155
if (dur > 0) timelineAdapter ??= adapter;
153156
}
154157

@@ -163,7 +166,10 @@ export function useTimelinePlayer() {
163166
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
164167
const adapter = wrapTimeline(win.__timelines[key]);
165168
const dur = getAdapterDuration(adapter);
166-
if (dur > 0 && docDuration <= dur) return adapter;
169+
if (dur > 0 && docDuration <= dur) {
170+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
171+
return adapter;
172+
}
167173
if (dur > 0) timelineAdapter ??= adapter;
168174
}
169175
}
@@ -182,26 +188,15 @@ export function useTimelinePlayer() {
182188
effectiveDuration > 0 &&
183189
("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function")
184190
) {
185-
const cached = staticSeekAdapterRef.current;
186-
if (cached?.player === bestAdapter && cached.duration === effectiveDuration) {
187-
return cached.adapter;
188-
}
189-
cached?.adapter.pause();
190-
console.warn(
191-
`[hyperframes-studio] Composition timeline duration (${adapterDur}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.`,
192-
);
193-
const adapter = createStaticSeekPlaybackAdapter(
191+
return resolveStaticSeekFallback({
192+
cache: staticSeekAdapterRef,
193+
warned: staticSeekWarnedRef,
194194
bestAdapter,
195195
effectiveDuration,
196-
getDefaultStaticSeekPlaybackClock(win),
197-
() => usePlayerStore.getState().playbackRate,
198-
);
199-
staticSeekAdapterRef.current = {
200-
player: bestAdapter,
201-
duration: effectiveDuration,
202-
adapter,
203-
};
204-
return adapter;
196+
docDuration,
197+
clock: getDefaultStaticSeekPlaybackClock(win),
198+
getPlaybackRate: () => usePlayerStore.getState().playbackRate,
199+
});
205200
}
206201

207202
return bestAdapter;
@@ -562,6 +557,7 @@ export function useTimelinePlayer() {
562557
document.removeEventListener("visibilitychange", handleVisibilityChange);
563558
stopRAFLoop();
564559
stopReverseLoop();
560+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
565561
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
566562
};
567563
});

packages/studio/src/player/lib/playbackAdapter.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { createStaticSeekPlaybackAdapter, wrapTimeline } from "./playbackAdapter";
2+
import {
3+
createStaticSeekPlaybackAdapter,
4+
wrapTimeline,
5+
resolveStaticSeekFallback,
6+
releaseStaticSeekCache,
7+
type StaticSeekCacheEntry,
8+
} from "./playbackAdapter";
39
import type {
410
RuntimePlaybackAdapter,
511
StaticSeekPlaybackClock,
@@ -211,3 +217,82 @@ describe("createStaticSeekPlaybackAdapter seek keepPlaying option", () => {
211217
expect(adapter.isPlaying()).toBe(false);
212218
});
213219
});
220+
221+
describe("static-seek fallback cache (resolveStaticSeekFallback / releaseStaticSeekCache)", () => {
222+
function makeClock(): StaticSeekPlaybackClock {
223+
return {
224+
now: () => 0,
225+
requestAnimationFrame: () => 0,
226+
cancelAnimationFrame: () => {},
227+
};
228+
}
229+
230+
function makePlayer() {
231+
return { getTime: () => 0, renderSeek: vi.fn() };
232+
}
233+
234+
function resolve(
235+
cache: { current: StaticSeekCacheEntry | null },
236+
warned: { current: boolean },
237+
player: ReturnType<typeof makePlayer>,
238+
duration: number,
239+
) {
240+
return resolveStaticSeekFallback({
241+
cache,
242+
warned,
243+
bestAdapter: player as unknown as RuntimePlaybackAdapter,
244+
effectiveDuration: duration,
245+
docDuration: duration,
246+
clock: makeClock(),
247+
getPlaybackRate: () => 1,
248+
});
249+
}
250+
251+
it("warns once per downgrade streak and re-arms after release", () => {
252+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
253+
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
254+
const warned = { current: false };
255+
const player = makePlayer();
256+
257+
resolve(cache, warned, player, 10);
258+
resolve(cache, warned, player, 11); // cache miss (new duration) — must not warn again
259+
expect(warn).toHaveBeenCalledTimes(1);
260+
261+
releaseStaticSeekCache(cache, warned);
262+
resolve(cache, warned, player, 12);
263+
expect(warn).toHaveBeenCalledTimes(2);
264+
warn.mockRestore();
265+
});
266+
267+
it("returns the cached adapter for the same player and duration", () => {
268+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
269+
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
270+
const warned = { current: false };
271+
const player = makePlayer();
272+
273+
const first = resolve(cache, warned, player, 10);
274+
const second = resolve(cache, warned, player, 10);
275+
expect(second).toBe(first);
276+
warn.mockRestore();
277+
});
278+
279+
it("pauses the replaced adapter on cache miss and the cached adapter on release", () => {
280+
vi.spyOn(console, "warn").mockImplementation(() => {});
281+
const cache: { current: StaticSeekCacheEntry | null } = { current: null };
282+
const warned = { current: false };
283+
const player = makePlayer();
284+
285+
const first = resolve(cache, warned, player, 10);
286+
first.play();
287+
expect(first.isPlaying()).toBe(true);
288+
const second = resolve(cache, warned, player, 20);
289+
expect(first.isPlaying()).toBe(false);
290+
291+
second.play();
292+
expect(second.isPlaying()).toBe(true);
293+
releaseStaticSeekCache(cache, warned);
294+
expect(second.isPlaying()).toBe(false);
295+
expect(cache.current).toBeNull();
296+
vi.restoreAllMocks();
297+
});
298+
});

packages/studio/src/player/lib/playbackAdapter.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,68 @@ export function createStaticSeekPlaybackAdapter(
134134
};
135135
}
136136

137+
// ---------------------------------------------------------------------------
138+
// Static-seek fallback cache
139+
// ---------------------------------------------------------------------------
140+
141+
export type StaticSeekCacheEntry = {
142+
player: RuntimePlaybackAdapter | PlaybackAdapter;
143+
duration: number;
144+
adapter: PlaybackAdapter;
145+
};
146+
147+
type StaticSeekCacheRef = { current: StaticSeekCacheEntry | null };
148+
type WarnedRef = { current: boolean };
149+
150+
/**
151+
* Pause and drop the cached static-seek adapter. Must be called whenever
152+
* adapter selection switches to a native adapter — a cached static-seek
153+
* adapter that was mid-play keeps its private rAF loop seeking the player
154+
* forever otherwise, fighting the native transport. Also re-arms the
155+
* downgrade warning so a later re-downgrade is surfaced again.
156+
*/
157+
export function releaseStaticSeekCache(cache: StaticSeekCacheRef, warned: WarnedRef): void {
158+
cache.current?.adapter.pause();
159+
cache.current = null;
160+
warned.current = false;
161+
}
162+
163+
/**
164+
* Resolve (with caching) the seek-driven fallback adapter. Warns once per
165+
* downgrade streak: seek-driven playback never starts media elements or
166+
* WebAudio, so without the warning the downgrade silently loses audio.
167+
*/
168+
export function resolveStaticSeekFallback(opts: {
169+
cache: StaticSeekCacheRef;
170+
warned: WarnedRef;
171+
bestAdapter: RuntimePlaybackAdapter | PlaybackAdapter;
172+
effectiveDuration: number;
173+
docDuration: number;
174+
clock: StaticSeekPlaybackClock;
175+
getPlaybackRate: () => number;
176+
}): PlaybackAdapter {
177+
const { cache, warned, bestAdapter, effectiveDuration, docDuration } = opts;
178+
const cached = cache.current;
179+
if (cached?.player === bestAdapter && cached.duration === effectiveDuration) {
180+
return cached.adapter;
181+
}
182+
cached?.adapter.pause();
183+
if (!warned.current) {
184+
warned.current = true;
185+
console.warn(
186+
`[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.`,
187+
);
188+
}
189+
const adapter = createStaticSeekPlaybackAdapter(
190+
bestAdapter,
191+
effectiveDuration,
192+
opts.clock,
193+
opts.getPlaybackRate,
194+
);
195+
cache.current = { player: bestAdapter, duration: effectiveDuration, adapter };
196+
return adapter;
197+
}
198+
137199
// ---------------------------------------------------------------------------
138200
// GSAP timeline wrapper
139201
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)