Skip to content

Commit 7a99cce

Browse files
vanceingallsclaude
andauthored
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>
1 parent 740f244 commit 7a99cce

5 files changed

Lines changed: 228 additions & 31 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,47 @@ describe("initSandboxRuntimeModular", () => {
282282
expect(slide3.style.visibility).toBe("visible");
283283
});
284284

285+
it("extends the playable duration to the root's declared data-duration when the timeline ends short", () => {
286+
const root = document.createElement("div");
287+
root.setAttribute("data-composition-id", "main");
288+
root.setAttribute("data-root", "true");
289+
root.setAttribute("data-start", "0");
290+
root.setAttribute("data-duration", "250.5");
291+
root.setAttribute("data-width", "1920");
292+
root.setAttribute("data-height", "1080");
293+
document.body.appendChild(root);
294+
295+
// GSAP timeline ends 0.1s short of the declared duration — the declared
296+
// data-duration must win, or duration-gated consumers (studio adapter
297+
// selection) reject the runtime player and audio is silently lost.
298+
window.__timelines = {
299+
main: createMockTimeline(250.4),
300+
};
301+
302+
initSandboxRuntimeModular();
303+
304+
expect(window.__player?.getDuration()).toBe(250.5);
305+
});
306+
307+
it("keeps the timeline duration when it exceeds the root's declared data-duration", () => {
308+
const root = document.createElement("div");
309+
root.setAttribute("data-composition-id", "main");
310+
root.setAttribute("data-root", "true");
311+
root.setAttribute("data-start", "0");
312+
root.setAttribute("data-duration", "10");
313+
root.setAttribute("data-width", "1920");
314+
root.setAttribute("data-height", "1080");
315+
document.body.appendChild(root);
316+
317+
window.__timelines = {
318+
main: createMockTimeline(12),
319+
};
320+
321+
initSandboxRuntimeModular();
322+
323+
expect(window.__player?.getDuration()).toBe(12);
324+
});
325+
285326
it("pauses nested media that is outside the timed-media cache after a seek", () => {
286327
const root = document.createElement("div");
287328
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,15 @@ export function initSandboxRuntimeModular(): void {
484484
includeAuthoredTimingAttrs: true,
485485
});
486486
let maxWindowEndSeconds = 0;
487+
// The root's own data-duration is the authored source of truth for
488+
// composition length. Without it in the floor, a GSAP timeline that ends
489+
// even slightly short of the declared duration shrinks the playable
490+
// window — and duration-gated consumers (e.g. the studio's adapter
491+
// selection) silently reject the runtime player, losing audio playback.
492+
const rootDeclaredSeconds = Number.parseFloat(rootEl.getAttribute("data-duration") ?? "");
493+
if (Number.isFinite(rootDeclaredSeconds) && rootDeclaredSeconds > 0) {
494+
maxWindowEndSeconds = rootDeclaredSeconds;
495+
}
487496
const compositionNodes = Array.from(
488497
rootEl.querySelectorAll("[data-composition-id][data-start]"),
489498
);
@@ -1960,8 +1969,11 @@ export function initSandboxRuntimeModular(): void {
19601969
}
19611970

19621971
// Keep clock duration in sync with the resolved timeline duration.
1963-
// Cheap (no DOM reads) and catches async timeline rebinds that happen
1964-
// 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.
19651977
if (state.capturedTimeline) {
19661978
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
19671979
if (dur > 0) clock.setDuration(dur);

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

Lines changed: 25 additions & 28 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,31 +140,36 @@ 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

155158
if (win.__timelines) {
156159
const keys = Object.keys(win.__timelines);
157160
if (keys.length > 0) {
158-
// Resolve the root composition id from the DOM — the outermost
159-
// `[data-composition-id]` element is the master. Without this,
160-
// Object.keys() order would let a sub-composition's timeline
161-
// hijack play/pause/seek and the duration readout.
161+
// Resolve the root composition id from the DOM — the outermost [data-composition-id]
162+
// is the master; otherwise Object.keys() order lets a sub-composition hijack transport.
162163
const rootId = iframe?.contentDocument
163164
?.querySelector("[data-composition-id]")
164165
?.getAttribute("data-composition-id");
165166
const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1];
166167
const adapter = wrapTimeline(win.__timelines[key]);
167168
const dur = getAdapterDuration(adapter);
168-
if (dur > 0 && docDuration <= dur) return adapter;
169+
if (dur > 0 && docDuration <= dur) {
170+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
171+
return adapter;
172+
}
169173
if (dur > 0) timelineAdapter ??= adapter;
170174
}
171175
}
@@ -184,23 +188,15 @@ export function useTimelinePlayer() {
184188
effectiveDuration > 0 &&
185189
("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function")
186190
) {
187-
const cached = staticSeekAdapterRef.current;
188-
if (cached?.player === bestAdapter && cached.duration === effectiveDuration) {
189-
return cached.adapter;
190-
}
191-
cached?.adapter.pause();
192-
const adapter = createStaticSeekPlaybackAdapter(
191+
return resolveStaticSeekFallback({
192+
cache: staticSeekAdapterRef,
193+
warned: staticSeekWarnedRef,
193194
bestAdapter,
194195
effectiveDuration,
195-
getDefaultStaticSeekPlaybackClock(win),
196-
() => usePlayerStore.getState().playbackRate,
197-
);
198-
staticSeekAdapterRef.current = {
199-
player: bestAdapter,
200-
duration: effectiveDuration,
201-
adapter,
202-
};
203-
return adapter;
196+
docDuration,
197+
clock: getDefaultStaticSeekPlaybackClock(win),
198+
getPlaybackRate: () => usePlayerStore.getState().playbackRate,
199+
});
204200
}
205201

206202
return bestAdapter;
@@ -561,6 +557,7 @@ export function useTimelinePlayer() {
561557
document.removeEventListener("visibilitychange", handleVisibilityChange);
562558
stopRAFLoop();
563559
stopReverseLoop();
560+
releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
564561
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
565562
};
566563
});

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)