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
+41
View File
@@ -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");
+14 -2
View File
@@ -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);