Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,78 @@ describe("initSandboxRuntimeModular", () => {
expect(pipVideo.style.visibility).toBe("hidden");
});

it("keeps a composition-local clip visible for its whole slot when its duration exceeds the mount offset", () => {
// TAB-792, from a user report: a scene went black for the last 3s of its
// own 4.375s slot. The clip is composition-local (`data-start="0"`) inside a
// host mounted at 2.96s, and the convention test used to look at the
// authored END: 0 + 4.375 = 4.375 > 2.96, so it read as root-global and was
// scheduled 0..4.375. The ancestor gate clipped the front, leaving
// 2.96..4.375 visible and 4.375..7.335 black.
//
// The giveaway was that making the clip LONGER made the hole BIGGER, which
// is why this asserts a late instant inside the slot rather than just the
// resolved start.
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "720");
root.setAttribute("data-height", "720");
document.body.appendChild(root);

const host = document.createElement("div");
host.setAttribute("data-composition-id", "scene-1");
host.setAttribute("data-composition-file", "compositions/scene-1.html");
host.setAttribute("data-start", "2.96");
host.setAttribute("data-duration", "4.375");
root.appendChild(host);

const innerRoot = document.createElement("div");
innerRoot.setAttribute("data-composition-id", "scene-1");
host.appendChild(innerRoot);

const sceneVideo = document.createElement("video");
sceneVideo.setAttribute("data-start", "0.000");
sceneVideo.setAttribute("data-duration", "4.375");
sceneVideo.setAttribute("data-media-start", "0.000");
Object.defineProperty(sceneVideo, "paused", { value: true, configurable: true });
Object.defineProperty(sceneVideo, "readyState", { value: 0, configurable: true });
Object.defineProperty(sceneVideo, "currentTime", {
value: 0,
writable: true,
configurable: true,
});
sceneVideo.load = () => {};
innerRoot.appendChild(sceneVideo);

(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: createMockTimeline(18.88),
"scene-1": createMockTimeline(4.375),
};

initSandboxRuntimeModular();

// Composition-local: the host offset applies.
expect(window.__hfResolveMediaStartSeconds?.(sceneVideo)).toBeCloseTo(2.96);

const player = (window as Window & { __player?: { seek: (timeSeconds: number) => void } })
.__player;
expect(player).toBeDefined();

player?.seek(3.5);
expect(sceneVideo.style.visibility).toBe("visible");
// The instants that were black before the fix.
player?.seek(5.5);
expect(sceneVideo.style.visibility).toBe("visible");
player?.seek(7.2);
expect(sceneVideo.style.visibility).toBe("visible");
// Still bounded by its own slot.
player?.seek(7.4);
expect(sceneVideo.style.visibility).toBe("hidden");
player?.seek(2.5);
expect(sceneVideo.style.visibility).toBe("hidden");
});

it("shows auto-injected video at host time, not at t=0", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
Expand Down
28 changes: 16 additions & 12 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,22 +631,26 @@ export function initSandboxRuntimeModular(): void {
// Both timing conventions exist in shipped projects:
// - composition-local media, e.g. host@20 + video@0 => root@20
// - legacy root-global PIP media, e.g. host@45.4 + video@45.4 => root@45.4
// Preserve the global value when its authored window already intersects
// the host's absolute window. Otherwise it is unambiguously local and
// must inherit the recursively-resolved host start.
const authoredDuration = parseNumeric(element.getAttribute("data-duration"));
// Which one a clip uses is decided by where it *starts*, never by where it
// ends. A composition-local clip is authored from its host's zero, so its
// start sits below the host's absolute start; a root-global clip is already
// in root time and starts at or after its host.
//
// TAB-792: this used to test the authored *end* instead, so any
// composition-local clip whose duration merely exceeded the mount offset
// was misread as root-global — `data-start="0"` with a 4.375s duration in a
// host mounted at 2.96s scheduled itself 0..4.375, the ancestor gate
// clipped the front, and the scene went black for the remaining 3s of its
// own slot. The tell was that making the clip *longer* made the hole
// bigger. The two branches below disagreed, and the start-based one was
// the correct half.
const hostDuration = context.inheritedDuration;
const hostEnd = hostDuration != null && hostDuration > 0 ? inheritedStart + hostDuration : null;
const authoredEnd =
authoredDuration != null && authoredDuration > 0
? authoredStart + authoredDuration
: authoredStart;
const overlapsHostWindow =
const authoredStartIsRootGlobal =
hostEnd == null
? authoredStart >= inheritedStart
: authoredStart < hostEnd &&
(authoredEnd > inheritedStart || authoredStart === inheritedStart);
return overlapsHostWindow ? authoredStart : inheritedStart + authoredStart;
: authoredStart >= inheritedStart && authoredStart < hostEnd;
return authoredStartIsRootGlobal ? authoredStart : inheritedStart + authoredStart;
};

window.__hfResolveMediaStartSeconds = resolveAbsoluteMediaStartSeconds;
Expand Down