From cc3ca2f9f7f42ace95da0f7b1ec03d5c8115f2df Mon Sep 17 00:00:00 2001 From: James Date: Fri, 10 Jul 2026 10:38:15 -0700 Subject: [PATCH] fix(core): preserve runtime transport contract --- packages/core/src/runtime/init.test.ts | 43 +++ packages/core/src/runtime/init.ts | 266 ++++++++---------- packages/core/src/runtime/player.test.ts | 28 ++ packages/core/src/runtime/player.ts | 24 ++ .../src/services/coreRuntimeBrowser.test.ts | 81 ++++++ 5 files changed, 299 insertions(+), 143 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 9c7700b5fc..5b126f5c49 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1284,6 +1284,49 @@ describe("initSandboxRuntimeModular", () => { expect(childTimeline.time()).toBeCloseTo(1, 1); }); + it.each([24, 30, 60, 30_000 / 1_001])( + "preserves public playback state across keepPlaying seeks at %s fps", + (fps) => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + vi.spyOn(console, "info").mockImplementation(() => {}); + window.requestAnimationFrame = + raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + + document.body.innerHTML = ` +
+ `; + window.__timelines = { main: createMockTimeline(10) }; + window.__HF_EXPORT_RENDER_SEEK_CONFIG = { + fps, + fpsSource: "render-options", + }; + + initSandboxRuntimeModular(); + + const player = window.__player; + player?.play(); + raf.step(500); + player?.seek(2.07, { keepPlaying: true }); + + const quantized = Math.floor(2.07 * fps + 1e-9) / fps; + expect(player?.isPlaying()).toBe(true); + expect(player?.getTime()).toBeCloseTo(quantized, 6); + + raf.step(500); + expect(player?.isPlaying()).toBe(true); + expect(player?.getTime()).toBeCloseTo(quantized + 0.5, 5); + }, + ); + it("sets __renderReady only after timeline is bound, not at __playerReady time", async () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index e311c19300..9ae848443a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -22,7 +22,7 @@ import { createWaapiAdapter } from "./adapters/waapi"; import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media"; import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js"; import { createPickerModule } from "./picker"; -import { createRuntimePlayer } from "./player"; +import { createRuntimePlayer, type RuntimePlayerTransport } from "./player"; import { createRuntimeState } from "./state"; import { collectRuntimeTimelinePayload } from "./timeline"; import { createRuntimeStartTimeResolver } from "./startResolver"; @@ -117,6 +117,16 @@ export function initSandboxRuntimeModular(): void { swallow("runtime.init.site1", err); } } + // Transport resources are initialized before any player or media closures. + // This removes the old temporal-dead-zone fallback and lets the public player + // be constructed once with its final clock-backed behavior. + const clock = new TransportClock(); + state.transportClock = clock; + const webAudio = new WebAudioTransport(); + let webAudioReady = false; + void webAudio.init().then((ok) => { + webAudioReady = ok; + }); // `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the // parser reads back), NOT an animatable property. Register it as a no-op GSAP // plugin so GSAP doesn't log "Invalid property _auto" on every tween build — @@ -2101,6 +2111,117 @@ export function initSandboxRuntimeModular(): void { } }; + const transport: RuntimePlayerTransport = { + play: () => { + const tl = state.capturedTimeline; + if (clock.isPlaying()) return; + const dur = getSafeTimelineDurationSeconds(tl, 0); + if (dur > 0) { + clock.setDuration(dur); + if (clock.reachedEnd()) { + clock.seek(0); + state.currentTime = 0; + seekTimelineAndAdapters(0); + } + } else { + const rootEl = resolveRootCompositionElement(); + const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0); + if (declaredDur > 0) clock.setDuration(declaredDur); + } + if (tl) tl.pause(); + if (!clock.play()) return; + state.isPlaying = true; + state.mediaForceSyncNextTick = true; + hardSyncAllMedia(clock.now()); + // Schedule audio through WebAudio for sample-accurate timing. + // Falls back to HTMLMediaElement playback if WebAudio isn't ready + // or decoding fails (the syncRuntimeMedia path handles that). + if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) { + scheduleWebAudioForActiveClips(); + } + runAdapters("play"); + syncMediaForCurrentState(); + colorGrading.redraw(); + postState(true); + }, + pause: () => { + if (!clock.isPlaying()) return; + webAudio.stopAll(); + clock.detachAudioSource(); + clock.pause(); + state.isPlaying = false; + state.currentTime = clock.now(); + state.mediaForceSyncNextTick = true; + hardSyncAllMedia(state.currentTime); + const tl = state.capturedTimeline; + if (tl) tl.pause(); + runAdapters("pause"); + syncMediaForCurrentState(); + colorGrading.redraw(); + postState(true); + }, + seek: (timeSeconds, options) => { + const quantized = quantizeTimeToFrame( + Math.max(0, Number(timeSeconds) || 0), + state.canonicalFps, + ); + webAudio.stopAll(); + clock.detachAudioSource(); + const wasPlaying = clock.isPlaying(); + if (wasPlaying) clock.pause(); + clock.seek(quantized); + state.currentTime = clock.now(); + state.isPlaying = false; + state.mediaForceSyncNextTick = true; + const tl = state.capturedTimeline; + if (tl) tl.pause(); + seekTimelineAndAdapters(state.currentTime); + runAdapters("pause"); + if (options?.keepPlaying && wasPlaying) { + transport.play(); + return; + } + syncMediaForCurrentState(); + colorGrading.redraw(); + postState(true); + }, + renderSeek: (timeSeconds, options) => { + const quantized = quantizeTimeToFrame( + Math.max(0, Number(timeSeconds) || 0), + state.canonicalFps, + ); + webAudio.stopAll(); + clock.detachAudioSource(); + if (clock.isPlaying()) clock.pause(); + clock.seek(quantized); + state.currentTime = clock.now(); + state.isPlaying = false; + state.mediaForceSyncNextTick = true; + seekTimelineAndAdapters(state.currentTime, { + activateChildren: true, + suppressEvents: options?.suppressEvents, + }); + syncMediaForCurrentState(); + colorGrading.redraw(); + postState(true); + }, + getTime: () => clock.now(), + getDuration: () => { + const dur = clock.getDuration(); + return Number.isFinite(dur) ? dur : 0; + }, + isPlaying: () => clock.isPlaying(), + setPlaybackRate: (rate) => { + applyPlaybackRate(rate); + clock.setRate(state.playbackRate); + applyWebAudioRate(); + }, + getPlaybackRate: () => state.playbackRate, + }; + + const initialDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); + if (initialDuration > 0) clock.setDuration(initialDuration); + const player = createRuntimePlayer({ getTimeline: () => state.capturedTimeline, setTimeline: (timeline) => { @@ -2144,6 +2265,7 @@ export function initSandboxRuntimeModular(): void { }, onShowNativeVideos: () => {}, getSafeDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0), + transport, }); window.__player = createPlayerApiCompat(player); @@ -2298,19 +2420,6 @@ export function initSandboxRuntimeModular(): void { installRuntimeErrorDiagnostics(); bindMediaMetadataListeners(); runAdapters("discover"); - // ── Single-clock transport ── - // - // TransportClock is the sole time authority. GSAP is always paused — - // seeked to clock.now() on each rAF tick. This eliminates the - // two-clock drift problem from issue #668: one clock, zero drift. - const clock = new TransportClock(); - state.transportClock = clock; - const webAudio = new WebAudioTransport(); - let webAudioReady = false; - void webAudio.init().then((ok) => { - webAudioReady = ok; - }); - const publishRenderReadyAfterTimelineBinding = () => { const prevTimeline = state.capturedTimeline; const rebound = bindRootTimelineIfAvailable(); @@ -2862,109 +2971,6 @@ export function initSandboxRuntimeModular(): void { } }; - player.play = () => { - const tl = state.capturedTimeline; - if (clock.isPlaying()) return; - const dur = getSafeTimelineDurationSeconds(tl, 0); - if (dur > 0) { - clock.setDuration(dur); - if (clock.reachedEnd()) { - clock.seek(0); - state.currentTime = 0; - seekTimelineAndAdapters(0); - } - } else { - const rootEl = resolveRootCompositionElement(); - const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0); - if (declaredDur > 0) clock.setDuration(declaredDur); - } - if (tl) tl.pause(); - if (!clock.play()) return; - state.isPlaying = true; - state.mediaForceSyncNextTick = true; - hardSyncAllMedia(clock.now()); - // Schedule audio through WebAudio for sample-accurate timing. - // Falls back to HTMLMediaElement playback if WebAudio isn't ready - // or decoding fails (the syncRuntimeMedia path handles that). - if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) { - scheduleWebAudioForActiveClips(); - } - runAdapters("play"); - syncMediaForCurrentState(); - colorGrading.redraw(); - postState(true); - }; - - player.pause = () => { - if (!clock.isPlaying()) return; - webAudio.stopAll(); - clock.detachAudioSource(); - clock.pause(); - state.isPlaying = false; - state.currentTime = clock.now(); - state.mediaForceSyncNextTick = true; - hardSyncAllMedia(state.currentTime); - const tl = state.capturedTimeline; - if (tl) tl.pause(); - runAdapters("pause"); - syncMediaForCurrentState(); - colorGrading.redraw(); - postState(true); - }; - - player.seek = (timeSeconds: number) => { - const quantized = quantizeTimeToFrame( - Math.max(0, Number(timeSeconds) || 0), - state.canonicalFps, - ); - webAudio.stopAll(); - clock.detachAudioSource(); - const wasPlaying = clock.isPlaying(); - if (wasPlaying) clock.pause(); - clock.seek(quantized); - state.currentTime = clock.now(); - state.isPlaying = false; - state.mediaForceSyncNextTick = true; - const tl = state.capturedTimeline; - if (tl) tl.pause(); - seekTimelineAndAdapters(state.currentTime); - runAdapters("pause"); - syncMediaForCurrentState(); - colorGrading.redraw(); - postState(true); - }; - - player.renderSeek = (timeSeconds: number, options?: RuntimeSeekOptions) => { - const quantized = quantizeTimeToFrame( - Math.max(0, Number(timeSeconds) || 0), - state.canonicalFps, - ); - if (clock.isPlaying()) clock.pause(); - clock.seek(quantized); - state.currentTime = clock.now(); - state.isPlaying = false; - state.mediaForceSyncNextTick = true; - seekTimelineAndAdapters(state.currentTime, { - activateChildren: true, - suppressEvents: options?.suppressEvents, - }); - syncMediaForCurrentState(); - colorGrading.redraw(); - postState(true); - }; - - player.getTime = () => clock.now(); - player.getDuration = () => { - const dur = clock.getDuration(); - return Number.isFinite(dur) ? dur : 0; - }; - player.isPlaying = () => clock.isPlaying(); - player.setPlaybackRate = (rate: number) => { - applyPlaybackRate(rate); - clock.setRate(state.playbackRate); - applyWebAudioRate(); - }; - // Sync clock duration from any captured timeline if (state.capturedTimeline) { const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); @@ -2972,32 +2978,6 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.pause(); } - // Re-delegate __player methods through the live `player` object so - // transport clock overrides are visible to iframe consumers reading - // window.__player. Uses property delegation so future methods added - // to createPlayerApiCompat are forwarded automatically. - const playerApi = window.__player; - if (playerApi) { - const delegated = [ - "play", - "pause", - "seek", - "renderSeek", - "getTime", - "getDuration", - "isPlaying", - ] as const; - for (const key of delegated) { - Object.defineProperty(playerApi, key, { - get: () => player[key], - set: (v: unknown) => { - (player as Record)[key] = v; - }, - configurable: true, - }); - } - } - installPositionEditsSeekReapply(window as Window & typeof globalThis); // Start the rAF tick loop diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index f0b5fa7d3f..377efb7b64 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -134,6 +134,34 @@ function createNestedTimelineHarness() { } describe("createRuntimePlayer", () => { + describe("dedicated transport", () => { + it("keeps factory-owned methods stable and forwards the complete seek contract", () => { + const timeline = createMockTimeline(); + const deps = createMockDeps(timeline); + const transport = { + play: vi.fn(), + pause: vi.fn(), + seek: vi.fn(), + renderSeek: vi.fn(), + getTime: vi.fn(() => 4), + getDuration: vi.fn(() => 10), + isPlaying: vi.fn(() => true), + setPlaybackRate: vi.fn(), + getPlaybackRate: vi.fn(() => 1.5), + }; + const player = createRuntimePlayer({ ...deps, transport }); + const seek = player.seek; + + player.seek(2.5, { keepPlaying: true }); + + expect(player.seek).toBe(seek); + expect(transport.seek).toHaveBeenCalledWith(2.5, { keepPlaying: true }); + expect(player.getTime()).toBe(4); + expect(player.getPlaybackRate()).toBe(1.5); + expect(deps.onDeterministicSeek).not.toHaveBeenCalled(); + }); + }); + describe("play", () => { it("does nothing without a timeline", () => { const deps = createMockDeps(null); diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index 6ffd382e79..9af28afc73 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -31,6 +31,8 @@ function safeVoid(obj: unknown, method: string): void { } } +export type RuntimePlayerTransport = Omit; + type PlayerDeps = { getTimeline: () => RuntimeTimelineLike | null; setTimeline: (timeline: RuntimeTimelineLike | null) => void; @@ -56,6 +58,12 @@ type PlayerDeps = { * animations would continue to advance visually past the paused time. */ getTimelineRegistry?: () => Record; + /** + * Optional transport implementation for runtimes with a dedicated clock. + * The public player methods remain factory-owned and stable for the lifetime + * of the runtime; callers must never replace them after construction. + */ + transport?: RuntimePlayerTransport; }; function forEachSiblingTimeline( @@ -128,6 +136,22 @@ function activateSiblingTimelines( } export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { + const transport = deps.transport; + if (transport) { + return { + _timeline: null, + play: () => transport.play(), + pause: () => transport.pause(), + seek: (timeSeconds, options) => transport.seek(timeSeconds, options), + renderSeek: (timeSeconds, options) => transport.renderSeek(timeSeconds, options), + getTime: () => transport.getTime(), + getDuration: () => transport.getDuration(), + isPlaying: () => transport.isPlaying(), + setPlaybackRate: (rate) => transport.setPlaybackRate(rate), + getPlaybackRate: () => transport.getPlaybackRate(), + }; + } + return { _timeline: null, play: () => { diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 9ca5d5053c..3147689f33 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -67,6 +67,87 @@ describe("core runtime browser contract", () => { }); }); + it.each([24, 30, 60, 30_000 / 1_001])( + "keeps the real public player running across a seek at %s fps", + async (fps) => { + const fpsPage = await browser.newPage(); + try { + await fpsPage.setContent(` + +
+
+
`); + await fpsPage.evaluate((runtimeFps) => { + ( + window as unknown as { + __HF_EXPORT_RENDER_SEEK_CONFIG?: { + fps: number; + fpsSource: "render-options"; + }; + } + ).__HF_EXPORT_RENDER_SEEK_CONFIG = { + fps: runtimeFps, + fpsSource: "render-options", + }; + }, fps); + await fpsPage.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") }); + await fpsPage.waitForFunction( + () => + (window as unknown as { __playerReady?: boolean }).__playerReady === true && + (window as unknown as { __renderReady?: boolean }).__renderReady === true, + ); + + const result = await fpsPage.evaluate(async () => { + const player = ( + window as unknown as { + __player?: { + play: () => void; + seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => void; + getTime: () => number; + isPlaying: () => boolean; + }; + } + ).__player; + if (!player) throw new Error("runtime player was not installed"); + + player.play(); + player.seek(1.123, { keepPlaying: true }); + const timeAfterSeek = player.getTime(); + const playingAfterSeek = player.isPlaying(); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 80)); + + return { + timeAfterSeek, + playingAfterSeek, + timeAfterDelay: player.getTime(), + playingAfterDelay: player.isPlaying(), + }; + }); + + const expectedSeek = Math.floor(1.123 * fps + 1e-9) / fps; + expect(result.timeAfterSeek).toBeCloseTo(expectedSeek, 1); + expect(result.playingAfterSeek).toBe(true); + expect(result.playingAfterDelay).toBe(true); + expect(result.timeAfterDelay).toBeGreaterThan(result.timeAfterSeek + 0.04); + } finally { + await fpsPage.close(); + } + }, + 30_000, + ); + it("removes the control bridge during teardown", async () => { const result = await page.evaluate(async () => { const runtimeWindow = window as unknown as {