Skip to content
Closed
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
119 changes: 119 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,125 @@ describe("initSandboxRuntimeModular", () => {
expect(hookHost.style.visibility).toBe("visible");
});

it("shows pip video at global start time even when host composition starts late", () => {
// Regression: resolveStartForElement used to add the host composition's start on top of
// the video's own data-start, causing double-offset. A pip video with data-start="45.40"
// inside a host at data-start="45.40" would resolve to 90.80 and stay permanently hidden.
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", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const host = document.createElement("div");
host.setAttribute("data-composition-id", "scene-pip");
host.setAttribute("data-start", "45.40");
host.setAttribute("data-duration", "7.06");
root.appendChild(host);

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

// pip-wired video: data-start is authored in global time (same value as host)
const pipVideo = document.createElement("video");
pipVideo.setAttribute("data-start", "45.40");
pipVideo.setAttribute("data-duration", "7.06");
Object.defineProperty(pipVideo, "paused", { value: true, configurable: true });
Object.defineProperty(pipVideo, "readyState", { value: 0, configurable: true });
Object.defineProperty(pipVideo, "currentTime", {
value: 0,
writable: true,
configurable: true,
});
pipVideo.load = () => {};
innerRoot.appendChild(pipVideo);

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

initSandboxRuntimeModular();

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

// Before the fix: resolveStartForElement(pipVideo) = 45.40 + 45.40 = 90.80, so the
// video would be hidden at t=46 (90.80 > 46). After the fix: start = 45.40, visible.
player?.seek(46);
expect(pipVideo.style.visibility).toBe("visible");

player?.seek(53);
expect(pipVideo.style.visibility).toBe("hidden");

player?.seek(44);
expect(pipVideo.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");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const host = document.createElement("div");
host.setAttribute("data-composition-id", "intro");
host.setAttribute("data-start", "10");
host.setAttribute("data-duration", "5");
root.appendChild(host);

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

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

(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
main: createMockTimeline(30),
intro: createMockTimeline(5),
};

initSandboxRuntimeModular();

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

player?.seek(12);
expect(video.style.visibility).toBe("visible");

player?.seek(5);
expect(video.style.visibility).toBe("hidden");

player?.seek(16);
expect(video.style.visibility).toBe("hidden");
});

it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1278,14 +1278,20 @@ export function initSandboxRuntimeModular(): void {
element.hasAttribute("data-start") ||
Boolean(resolveMediaCompositionContext(element).compositionRoot),
resolveStartSeconds: (element) => {
if (!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")) {
return Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0);
}
const context = resolveMediaCompositionContext(
element as HTMLVideoElement | HTMLAudioElement,
);
return resolveStartForElement(element, context.inheritedStart ?? 0);
},
resolveDurationSeconds: (element) => {
const context = resolveMediaCompositionContext(element);
const start = resolveStartForElement(element, context.inheritedStart ?? 0);
const start =
!element.hasAttribute("data-hf-auto-start") && element.hasAttribute("data-start")
? Math.max(0, Number(element.getAttribute("data-start") ?? 0) || 0)
: resolveStartForElement(element, context.inheritedStart ?? 0);
const mediaStart =
Number.parseFloat(element.dataset.playbackStart ?? element.dataset.mediaStart ?? "0") ||
0;
Expand Down Expand Up @@ -1329,7 +1335,16 @@ export function initSandboxRuntimeModular(): void {
const tag = rawNode.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;

const start = resolveStartForElement(rawNode, 0);
// Media elements with explicitly authored data-start (no data-hf-auto-start
// marker) use global coordinates — matching the render pipeline's
// discoverMediaFromBrowser. resolveStartForElement would add the host
// composition's offset a second time. Auto-injected data-start="0"
// (data-hf-auto-start present) is composition-local and needs the resolver.
const isGlobalMediaStart =
(tag === "video" || tag === "audio") && !rawNode.hasAttribute("data-hf-auto-start");
const start = isGlobalMediaStart
? Math.max(0, Number(rawNode.getAttribute("data-start") ?? 0) || 0)
: resolveStartForElement(rawNode, 0);
let duration = resolveDurationForElement(rawNode);
const compId = rawNode.getAttribute("data-composition-id");
if (compId) {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ export function collectRuntimeTimelinePayload(params: {
if (mediaNodes.length === 0) return null;
let maxWindowEndSeconds = 0;
for (const mediaNode of mediaNodes) {
const start = startResolver.resolveStartForElement(mediaNode, 0);
const start = !mediaNode.hasAttribute("data-hf-auto-start")
? Math.max(0, Number(mediaNode.getAttribute("data-start") ?? 0) || 0)
: startResolver.resolveStartForElement(mediaNode, 0);
if (!Number.isFinite(start)) continue;
const duration = resolveMediaElementDurationSeconds(mediaNode);
if (duration == null || duration <= 0) continue;
Expand Down
Loading