Skip to content
Merged
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
15 changes: 15 additions & 0 deletions packages/core/src/runtime/adapters/seek-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,21 @@ export function isSeekCompletionBarrierActive(): boolean {
return _activeCompletionBarriers > 0;
}

/** Settles with every pending completion, without consuming a failure the barrier reports. */
export async function seekCompletionsSettled(): Promise<void> {
for (
let pending = [..._pendingCompletions];
pending.length > 0;
pending = [..._pendingCompletions]
) {
await Promise.all(pending);
}
}

export function hasPendingSeekCompletion(): boolean {
return _pendingCompletions.size > 0;
}

/** `videoFrames: false` skips only the video-frame holds: a geometry read needs every other completion. */
export async function waitForSeekCompletion(options?: { videoFrames?: boolean }): Promise<void> {
const awaited = () =>
Expand Down
90 changes: 90 additions & 0 deletions packages/core/src/runtime/init.seekOrder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, expect, it, vi } from "vitest";
import { registerSeekCompletion } from "./adapters/seek-dispatch";
import { initSandboxRuntimeModular } from "./init";
import { createMockTimeline, resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers";

it("asks the on-screen video for the jump's frame before rendering the page there", () => {
resetRuntimeFixtureDom();
vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 0);
document.body.innerHTML =
'<div data-composition-id="main" data-root="true" data-duration="60"><video id="clip" data-start="30" data-duration="10" src="/movie.mp4"></video></div>';
const video = document.querySelector("video")!;
Object.defineProperty(video, "duration", { value: 10, configurable: true });
Object.defineProperty(video, "readyState", { value: 1, configurable: true });
Object.defineProperty(video, "paused", { value: true, configurable: true });
video.pause = vi.fn();
video.load = vi.fn();
const order: string[] = [];
vi.spyOn(video, "currentTime", "set").mockImplementation(() => order.push("video"));
const main = createMockTimeline(60);
const seekMain = main.totalTime;
main.totalTime = (time?: number) => {
if (time !== undefined) order.push("page");
return seekMain(time);
};
window.__timelines = { main };
initSandboxRuntimeModular();
order.length = 0;
window.__player!.seek(35);
expect(order[0]).toBe("video");
expect(order).toContain("page");
});

function mountTimingFilm() {
resetRuntimeFixtureDom();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval"] });
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => frames.push(callback));
const step = (count = 1) => {
for (let i = 0; i < count; i += 1) {
for (const callback of frames.splice(0)) callback(performance.now());
vi.advanceTimersByTime(20);
}
};
const posts: string[] = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
const type = (message as { type?: string }).type;
if (type === "timeline") posts.push(type);
});
document.body.innerHTML =
'<div data-composition-id="main" data-root="true" data-duration="60"><div id="clip" data-start="30" data-duration="10"></div></div>';
window.__timelines = { main: createMockTimeline(60) };
initSandboxRuntimeModular();
step(40);
let land = () => {};
registerSeekCompletion(new Promise<void>((resolve) => (land = resolve)));
posts.length = 0;
const editTiming = async () => {
document.getElementById("clip")!.setAttribute("data-duration", "12");
await Promise.resolve();
step(40);
};
return { step, posts, land: () => land(), editTiming };
}

it("posts the clip list once a jump has landed, not while it lands", async () => {
const film = mountTimingFilm();
window.__player!.seek(35);
await film.editTiming();
expect(film.posts).toHaveLength(0);
film.land();
await vi.waitFor(() => {
film.step();
expect(film.posts).toHaveLength(1);
});
vi.useRealTimers();
});

it("posts at once when nothing was jumped to, so readers at load see the clip list", async () => {
const film = mountTimingFilm();
await film.editTiming();
expect(film.posts.length).toBeGreaterThanOrEqual(1);
film.land();
vi.useRealTimers();
});

afterEach(() => {
window.__hfRuntimeTeardown?.();
vi.restoreAllMocks();
resetRuntimeFixtureDom();
});
28 changes: 26 additions & 2 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import {
} from "./adapters/video-texture-compat";
import {
forceDispatchSeekEvent,
hasPendingSeekCompletion,
registerSeekCompletion,
seekCompletionsSettled,
waitForSeekCompletion,
} from "./adapters/seek-dispatch";
import { sourceTimeAt } from "../speedRamp";
Expand Down Expand Up @@ -3461,6 +3463,22 @@ export function initSandboxRuntimeModular(): void {
};
// Studio re-reads the whole manifest on every post, so playback holds them until it stops.
let timelinePostHeld = false;
// A jump paints its frame first; the posts its scene mounts cause become one after.
let jumpLanding = false;
let timelinePostAfterSeek = false;
const postTimelineAfterSeek = () => {
if (timelinePostAfterSeek) return;
timelinePostAfterSeek = true;
void seekCompletionsSettled().then(() =>
window.requestAnimationFrame(() =>
setTimeout(() => {
timelinePostAfterSeek = false;
jumpLanding = false;
if (!state.tornDown) postTimeline();
}),
),
);
};
const postTimeline = () => {
sanitizeCompositionDurationAttributes();
applyCompositionSizing();
Expand All @@ -3476,6 +3494,9 @@ export function initSandboxRuntimeModular(): void {
bindRootTimelineIfAvailable();
timelinePostHeld = clock.isPlaying();
if (timelinePostHeld) return;
if (jumpLanding && !renderCaptureSeekStarted && hasPendingSeekCompletion())
return postTimelineAfterSeek();
jumpLanding = false;
const payload = collectRuntimeTimelinePayload({
canonicalFps: state.canonicalFps,
});
Expand Down Expand Up @@ -4049,15 +4070,18 @@ export function initSandboxRuntimeModular(): void {
state.currentTime = clock.now();
state.isPlaying = false;
state.mediaForceSyncNextTick = true;
const keepPlaying = options?.keepPlaying && wasPlaying;
jumpLanding = !keepPlaying;
// Decoding starts before the page renders, so the two overlap.
if (!keepPlaying) syncMediaForCurrentState();
const tl = state.capturedTimeline;
pauseTimelineIfPossible(tl);
const pageAnimations = seekTimelineAndAdapters(state.currentTime);
runAdapters("pause", 0, pageAnimations);
if (options?.keepPlaying && wasPlaying) {
if (keepPlaying) {
transport.play();
return;
}
syncMediaForCurrentState();
colorGrading.redraw();
paintVfx(state.currentTime);
postState(true);
Expand Down
Loading