diff --git a/packages/core/src/runtime/adapters/seek-dispatch.ts b/packages/core/src/runtime/adapters/seek-dispatch.ts index 8bce055eef..2173e8b110 100644 --- a/packages/core/src/runtime/adapters/seek-dispatch.ts +++ b/packages/core/src/runtime/adapters/seek-dispatch.ts @@ -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 { + 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 { const awaited = () => diff --git a/packages/core/src/runtime/init.seekOrder.test.ts b/packages/core/src/runtime/init.seekOrder.test.ts new file mode 100644 index 0000000000..6a30f1d43e --- /dev/null +++ b/packages/core/src/runtime/init.seekOrder.test.ts @@ -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 = + '
'; + 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 = + '
'; + window.__timelines = { main: createMockTimeline(60) }; + initSandboxRuntimeModular(); + step(40); + let land = () => {}; + registerSeekCompletion(new Promise((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(); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 4263e437a2..a831cb9d3c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -44,7 +44,9 @@ import { } from "./adapters/video-texture-compat"; import { forceDispatchSeekEvent, + hasPendingSeekCompletion, registerSeekCompletion, + seekCompletionsSettled, waitForSeekCompletion, } from "./adapters/seek-dispatch"; import { sourceTimeAt } from "../speedRamp"; @@ -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(); @@ -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, }); @@ -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);