Skip to content

Commit e1e062c

Browse files
committed
fix(runtime): decide a nested clip's timing convention by its start, not its end
`resolveAbsoluteMediaStartSeconds` disambiguates the two conventions #2859 identified: composition-local host@20 + video@0 => root@20 legacy root-global host@45.4 + video@45.4 => root@45.4 It decides by asking whether the clip's authored *end* lands inside the host window. What distinguishes the two is where the clip *starts*: a composition-local clip is authored from its host's zero, so its start sits below the host's absolute start. Its duration says nothing about which convention it uses. So a composition-local clip is misread as root-global whenever its duration merely exceeds the mount offset: data-start="0", data-duration="4.375", host mounted at 2.96 authoredEnd = 0 + 4.375 = 4.375 > 2.96 -> treated as root-global -> scheduled 0..4.375; the ancestor visibility gate clips the front -> visible 2.96..4.375, blank for the remaining ~3s of its own slot The two branches of that test already disagreed — the no-host-duration branch tested `authoredStart >= inheritedStart`, the other tested the end. This makes both use the start. This is the same failure #2859 fixed, one case further along: that PR handled the clip whose end falls *before* the mount offset (which falls through to local). A clip whose end falls *after* it flips back to global instead. The condition only produces a visible hole when 0 < mountOffset < duration, so a project can look entirely healthy while carrying it — every other scene in the project where we hit this mounts later than its own duration and resolved correctly by luck. Behaviour changes only for clips with 0 <= authoredStart < hostStart. The existing pip-video-late-host fixture (data-start 3.0 inside a host at 3.0) is unaffected by the `>=`, and its golden render still passes, as does nested-sequential-video-local-start.
1 parent 049f561 commit e1e062c

2 files changed

Lines changed: 88 additions & 12 deletions

File tree

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,78 @@ describe("initSandboxRuntimeModular", () => {
16331633
expect(pipVideo.style.visibility).toBe("hidden");
16341634
});
16351635

1636+
it("keeps a composition-local clip visible for its whole slot when its duration exceeds the mount offset", () => {
1637+
// TAB-792, from a user report: a scene went black for the last 3s of its
1638+
// own 4.375s slot. The clip is composition-local (`data-start="0"`) inside a
1639+
// host mounted at 2.96s, and the convention test used to look at the
1640+
// authored END: 0 + 4.375 = 4.375 > 2.96, so it read as root-global and was
1641+
// scheduled 0..4.375. The ancestor gate clipped the front, leaving
1642+
// 2.96..4.375 visible and 4.375..7.335 black.
1643+
//
1644+
// The giveaway was that making the clip LONGER made the hole BIGGER, which
1645+
// is why this asserts a late instant inside the slot rather than just the
1646+
// resolved start.
1647+
const root = document.createElement("div");
1648+
root.setAttribute("data-composition-id", "main");
1649+
root.setAttribute("data-root", "true");
1650+
root.setAttribute("data-start", "0");
1651+
root.setAttribute("data-width", "720");
1652+
root.setAttribute("data-height", "720");
1653+
document.body.appendChild(root);
1654+
1655+
const host = document.createElement("div");
1656+
host.setAttribute("data-composition-id", "scene-1");
1657+
host.setAttribute("data-composition-file", "compositions/scene-1.html");
1658+
host.setAttribute("data-start", "2.96");
1659+
host.setAttribute("data-duration", "4.375");
1660+
root.appendChild(host);
1661+
1662+
const innerRoot = document.createElement("div");
1663+
innerRoot.setAttribute("data-composition-id", "scene-1");
1664+
host.appendChild(innerRoot);
1665+
1666+
const sceneVideo = document.createElement("video");
1667+
sceneVideo.setAttribute("data-start", "0.000");
1668+
sceneVideo.setAttribute("data-duration", "4.375");
1669+
sceneVideo.setAttribute("data-media-start", "0.000");
1670+
Object.defineProperty(sceneVideo, "paused", { value: true, configurable: true });
1671+
Object.defineProperty(sceneVideo, "readyState", { value: 0, configurable: true });
1672+
Object.defineProperty(sceneVideo, "currentTime", {
1673+
value: 0,
1674+
writable: true,
1675+
configurable: true,
1676+
});
1677+
sceneVideo.load = () => {};
1678+
innerRoot.appendChild(sceneVideo);
1679+
1680+
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
1681+
main: createMockTimeline(18.88),
1682+
"scene-1": createMockTimeline(4.375),
1683+
};
1684+
1685+
initSandboxRuntimeModular();
1686+
1687+
// Composition-local: the host offset applies.
1688+
expect(window.__hfResolveMediaStartSeconds?.(sceneVideo)).toBeCloseTo(2.96);
1689+
1690+
const player = (window as Window & { __player?: { seek: (timeSeconds: number) => void } })
1691+
.__player;
1692+
expect(player).toBeDefined();
1693+
1694+
player?.seek(3.5);
1695+
expect(sceneVideo.style.visibility).toBe("visible");
1696+
// The instants that were black before the fix.
1697+
player?.seek(5.5);
1698+
expect(sceneVideo.style.visibility).toBe("visible");
1699+
player?.seek(7.2);
1700+
expect(sceneVideo.style.visibility).toBe("visible");
1701+
// Still bounded by its own slot.
1702+
player?.seek(7.4);
1703+
expect(sceneVideo.style.visibility).toBe("hidden");
1704+
player?.seek(2.5);
1705+
expect(sceneVideo.style.visibility).toBe("hidden");
1706+
});
1707+
16361708
it("shows auto-injected video at host time, not at t=0", () => {
16371709
const root = document.createElement("div");
16381710
root.setAttribute("data-composition-id", "main");

packages/core/src/runtime/init.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -631,22 +631,26 @@ export function initSandboxRuntimeModular(): void {
631631
// Both timing conventions exist in shipped projects:
632632
// - composition-local media, e.g. host@20 + video@0 => root@20
633633
// - legacy root-global PIP media, e.g. host@45.4 + video@45.4 => root@45.4
634-
// Preserve the global value when its authored window already intersects
635-
// the host's absolute window. Otherwise it is unambiguously local and
636-
// must inherit the recursively-resolved host start.
637-
const authoredDuration = parseNumeric(element.getAttribute("data-duration"));
634+
// Which one a clip uses is decided by where it *starts*, never by where it
635+
// ends. A composition-local clip is authored from its host's zero, so its
636+
// start sits below the host's absolute start; a root-global clip is already
637+
// in root time and starts at or after its host.
638+
//
639+
// TAB-792: this used to test the authored *end* instead, so any
640+
// composition-local clip whose duration merely exceeded the mount offset
641+
// was misread as root-global — `data-start="0"` with a 4.375s duration in a
642+
// host mounted at 2.96s scheduled itself 0..4.375, the ancestor gate
643+
// clipped the front, and the scene went black for the remaining 3s of its
644+
// own slot. The tell was that making the clip *longer* made the hole
645+
// bigger. The two branches below disagreed, and the start-based one was
646+
// the correct half.
638647
const hostDuration = context.inheritedDuration;
639648
const hostEnd = hostDuration != null && hostDuration > 0 ? inheritedStart + hostDuration : null;
640-
const authoredEnd =
641-
authoredDuration != null && authoredDuration > 0
642-
? authoredStart + authoredDuration
643-
: authoredStart;
644-
const overlapsHostWindow =
649+
const authoredStartIsRootGlobal =
645650
hostEnd == null
646651
? authoredStart >= inheritedStart
647-
: authoredStart < hostEnd &&
648-
(authoredEnd > inheritedStart || authoredStart === inheritedStart);
649-
return overlapsHostWindow ? authoredStart : inheritedStart + authoredStart;
652+
: authoredStart >= inheritedStart && authoredStart < hostEnd;
653+
return authoredStartIsRootGlobal ? authoredStart : inheritedStart + authoredStart;
650654
};
651655

652656
window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds;

0 commit comments

Comments
 (0)