mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Fable 5
parent
740f244c03
commit
7a99ccec6d
@@ -282,6 +282,47 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(slide3.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("extends the playable duration to the root's declared data-duration when the timeline ends short", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "250.5");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
// GSAP timeline ends 0.1s short of the declared duration — the declared
|
||||
// data-duration must win, or duration-gated consumers (studio adapter
|
||||
// selection) reject the runtime player and audio is silently lost.
|
||||
window.__timelines = {
|
||||
main: createMockTimeline(250.4),
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(window.__player?.getDuration()).toBe(250.5);
|
||||
});
|
||||
|
||||
it("keeps the timeline duration when it exceeds the root's declared data-duration", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-duration", "10");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
window.__timelines = {
|
||||
main: createMockTimeline(12),
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(window.__player?.getDuration()).toBe(12);
|
||||
});
|
||||
|
||||
it("pauses nested media that is outside the timed-media cache after a seek", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -484,6 +484,15 @@ export function initSandboxRuntimeModular(): void {
|
||||
includeAuthoredTimingAttrs: true,
|
||||
});
|
||||
let maxWindowEndSeconds = 0;
|
||||
// The root's own data-duration is the authored source of truth for
|
||||
// composition length. Without it in the floor, a GSAP timeline that ends
|
||||
// even slightly short of the declared duration shrinks the playable
|
||||
// window — and duration-gated consumers (e.g. the studio's adapter
|
||||
// selection) silently reject the runtime player, losing audio playback.
|
||||
const rootDeclaredSeconds = Number.parseFloat(rootEl.getAttribute("data-duration") ?? "");
|
||||
if (Number.isFinite(rootDeclaredSeconds) && rootDeclaredSeconds > 0) {
|
||||
maxWindowEndSeconds = rootDeclaredSeconds;
|
||||
}
|
||||
const compositionNodes = Array.from(
|
||||
rootEl.querySelectorAll("[data-composition-id][data-start]"),
|
||||
);
|
||||
@@ -1960,8 +1969,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
|
||||
// Keep clock duration in sync with the resolved timeline duration.
|
||||
// Cheap (no DOM reads) and catches async timeline rebinds that happen
|
||||
// outside the 60-tick branch (metadata hydration, deferred setTimeout).
|
||||
// Catches async timeline rebinds that happen outside the 60-tick
|
||||
// branch (metadata hydration, deferred setTimeout). Note: this reads
|
||||
// the DOM each tick (duration floors query authored windows + the
|
||||
// root's declared data-duration), which also keeps live edits to
|
||||
// data-duration in the studio reflected without a rebind.
|
||||
if (state.capturedTimeline) {
|
||||
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (dur > 0) clock.setDuration(dur);
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user