diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index daabc4dbd..8cbde87b5 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1845,6 +1845,59 @@ describe("initSandboxRuntimeModular", () => { expect(seekTimes[seekTimes.length - 1]).toBe(0); }); + it("accepts replayed transport controls when the bridge announces ready without duplicate listeners", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "root"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-duration", "5"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const timeline = createMockTimeline(5); + timeline.timeScale = vi.fn(); + window.__timelines = { root: timeline }; + const outbound: Array> = []; + vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => { + if (typeof message !== "object" || message === null) return; + const payload = message as Record; + outbound.push(payload); + if (payload.source !== "hf-preview" || payload.type !== "ready") return; + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: "hf-parent", + type: "control", + action: "seek", + timeSeconds: 2, + }, + }), + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: "hf-parent", + type: "control", + action: "set-playback-rate", + playbackRate: 2, + }, + }), + ); + }); + + expect(() => initSandboxRuntimeModular()).not.toThrow(); + expect(() => initSandboxRuntimeModular()).not.toThrow(); + + expect(timeline.time()).toBe(2); + expect(timeline.timeScale).toHaveBeenLastCalledWith(2); + expect(outbound.filter((message) => message.type === "ready")).toHaveLength(2); + expect( + outbound.filter( + (message) => message.type === "analytics" && message.event === "composition_seeked", + ), + ).toHaveLength(2); + }); + it("restores timed element visibility after a forced timeline rebind", () => { document.body.innerHTML = `
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 14b74ddf6..06e8a31d6 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2373,118 +2373,6 @@ export function initSandboxRuntimeModular(): void { document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null, }); - state.controlBridgeHandler = installRuntimeControlBridge({ - onPlay: () => { - player.play(); - emitAnalyticsEvent("composition_played", { time: player.getTime() }); - }, - onPause: () => { - player.pause(); - emitAnalyticsEvent("composition_paused", { time: player.getTime() }); - }, - onStopMedia: () => { - webAudio.stopAll(); - const mediaEls = document.querySelectorAll("video, audio"); - for (const el of mediaEls) { - if (el instanceof HTMLMediaElement && !el.paused) el.pause(); - } - }, - onSeek: (timeSeconds, _seekMode) => { - player.seek(timeSeconds); - emitAnalyticsEvent("composition_seeked", { time: timeSeconds }); - }, - onSetMuted: (muted) => { - state.bridgeMuted = muted; - const effective = muted || state.mediaOutputMuted; - webAudio.setMuted(effective); - const mediaEls = document.querySelectorAll("video, audio"); - for (const el of mediaEls) { - if (!(el instanceof HTMLMediaElement)) continue; - el.muted = effective || el.defaultMuted; - } - }, - onSetVolume: (volume) => { - state.bridgeVolume = volume; - webAudio.setVolume(volume); - const mediaEls = document.querySelectorAll("video, audio"); - for (const el of mediaEls) { - if (!(el instanceof HTMLMediaElement)) continue; - const parsed = parseFloat(el.dataset.volume ?? ""); - const clipVolume = Number.isFinite(parsed) ? parsed : 1; - el.volume = clipVolume * volume; - } - }, - onSetMediaOutputMuted: (muted) => { - state.mediaOutputMuted = muted; - const effective = muted || state.bridgeMuted; - webAudio.setMuted(effective); - const mediaEls = document.querySelectorAll("video, audio"); - for (const el of mediaEls) { - if (!(el instanceof HTMLMediaElement)) continue; - el.muted = effective || el.defaultMuted; - } - }, - onSetNativeMediaSyncDisabled: (disabled) => { - if (state.nativeMediaSyncDisabled === disabled) return; - state.nativeMediaSyncDisabled = disabled; - state.mediaForceSyncNextTick = true; - if (disabled) { - webAudio.stopAll(); - clock.detachAudioSource(); - } else { - syncMediaForCurrentState(); - } - }, - onSetWebAudioMediaDisabled: (disabled) => { - if (state.webAudioMediaDisabled === disabled) return; - state.webAudioMediaDisabled = disabled; - state.mediaForceSyncNextTick = true; - if (disabled) { - webAudio.stopAll(); - clock.detachAudioSource(); - syncMediaForCurrentState(); - } else { - syncMediaForCurrentState(); - } - }, - onSetPlaybackRate: (rate) => { - applyPlaybackRate(rate); - if (state.transportClock) state.transportClock.setRate(state.playbackRate); - applyWebAudioRate(); - }, - onSetRootDuration: growRootDurationLive, - onSetColorGrading: (target, grading) => { - colorGrading.setGrading(target, grading); - }, - onSetColorGradingCompare: (target, compare) => { - colorGrading.setCompare(target, compare); - }, - onTick: () => { - if (state.tornDown || !clock.isPlaying()) return; - const t = clock.now(); - state.currentTime = t; - seekTimelineAndAdapters(t); - if (clock.reachedEnd()) { - webAudio.stopAll(); - clock.detachAudioSource(); - clock.pause(); - state.isPlaying = false; - const dur = clock.getDuration(); - if (Number.isFinite(dur)) { - clock.seek(dur); - state.currentTime = dur; - seekTimelineAndAdapters(dur); - } - runAdapters("pause"); - syncMediaForCurrentState(); - postState(true); - } - }, - onEnablePickMode: () => picker.enablePickMode(), - onDisablePickMode: () => picker.disablePickMode(), - getCanonicalFps: () => state.canonicalFps, - }); - state.deterministicAdapters = [ createWaapiAdapter(), createCssAdapter({ @@ -2756,10 +2644,10 @@ export function initSandboxRuntimeModular(): void { return false; }; - const seekTimelineAndAdapters = ( + function seekTimelineAndAdapters( t: number, opts?: { activateChildren?: boolean; suppressEvents?: boolean }, - ) => { + ) { const tl = state.capturedTimeline; const suppressEvents = opts?.suppressEvents === true; if (tl) { @@ -2825,7 +2713,7 @@ export function initSandboxRuntimeModular(): void { swallow("runtime.init.transport.adapter", err); } } - }; + } // True while the Studio is mid-drag on an element (the gesture marker is // stamped on the gestured element for the duration of the drag). During a @@ -3061,7 +2949,7 @@ export function initSandboxRuntimeModular(): void { // rescaled in place; but a bounded source's window was baked into start()'s // duration at its prior rate and can't be rescaled, so when one is active we // stopAll()+reschedule at the new rate to keep trimmed clips ending on time. - const applyWebAudioRate = () => { + function applyWebAudioRate() { const changed = webAudio.setRate(state.playbackRate); if ( changed && @@ -3074,7 +2962,7 @@ export function initSandboxRuntimeModular(): void { webAudio.stopAll(); scheduleWebAudioForActiveClips(); } - }; + } // Sync clock duration from any captured timeline if (state.capturedTimeline) { @@ -3090,6 +2978,123 @@ export function initSandboxRuntimeModular(): void { postTimeline(); postState(true); + // Wire the control bridge LAST — after every transport helper its handlers + // dispatch to (seekTimelineAndAdapters, applyWebAudioRate, ...) is declared. + // The runtime's external control surface only goes live once all of its + // dependencies exist, so a load-time seek / set-playback-rate can never reach + // a not-yet-initialized helper (the 'before initialization' TDZ this fixes). + state.controlBridgeHandler = installRuntimeControlBridge({ + onPlay: () => { + player.play(); + emitAnalyticsEvent("composition_played", { time: player.getTime() }); + }, + onPause: () => { + player.pause(); + emitAnalyticsEvent("composition_paused", { time: player.getTime() }); + }, + onStopMedia: () => { + webAudio.stopAll(); + const mediaEls = document.querySelectorAll("video, audio"); + for (const el of mediaEls) { + if (el instanceof HTMLMediaElement && !el.paused) el.pause(); + } + }, + onSeek: (timeSeconds, _seekMode) => { + player.seek(timeSeconds); + emitAnalyticsEvent("composition_seeked", { time: timeSeconds }); + }, + onSetMuted: (muted) => { + state.bridgeMuted = muted; + const effective = muted || state.mediaOutputMuted; + webAudio.setMuted(effective); + const mediaEls = document.querySelectorAll("video, audio"); + for (const el of mediaEls) { + if (!(el instanceof HTMLMediaElement)) continue; + el.muted = effective || el.defaultMuted; + } + }, + onSetVolume: (volume) => { + state.bridgeVolume = volume; + webAudio.setVolume(volume); + const mediaEls = document.querySelectorAll("video, audio"); + for (const el of mediaEls) { + if (!(el instanceof HTMLMediaElement)) continue; + const parsed = parseFloat(el.dataset.volume ?? ""); + const clipVolume = Number.isFinite(parsed) ? parsed : 1; + el.volume = clipVolume * volume; + } + }, + onSetMediaOutputMuted: (muted) => { + state.mediaOutputMuted = muted; + const effective = muted || state.bridgeMuted; + webAudio.setMuted(effective); + const mediaEls = document.querySelectorAll("video, audio"); + for (const el of mediaEls) { + if (!(el instanceof HTMLMediaElement)) continue; + el.muted = effective || el.defaultMuted; + } + }, + onSetNativeMediaSyncDisabled: (disabled) => { + if (state.nativeMediaSyncDisabled === disabled) return; + state.nativeMediaSyncDisabled = disabled; + state.mediaForceSyncNextTick = true; + if (disabled) { + webAudio.stopAll(); + clock.detachAudioSource(); + } else { + syncMediaForCurrentState(); + } + }, + onSetWebAudioMediaDisabled: (disabled) => { + if (state.webAudioMediaDisabled === disabled) return; + state.webAudioMediaDisabled = disabled; + state.mediaForceSyncNextTick = true; + if (disabled) { + webAudio.stopAll(); + clock.detachAudioSource(); + syncMediaForCurrentState(); + } else { + syncMediaForCurrentState(); + } + }, + onSetPlaybackRate: (rate) => { + applyPlaybackRate(rate); + if (state.transportClock) state.transportClock.setRate(state.playbackRate); + applyWebAudioRate(); + }, + onSetRootDuration: growRootDurationLive, + onSetColorGrading: (target, grading) => { + colorGrading.setGrading(target, grading); + }, + onSetColorGradingCompare: (target, compare) => { + colorGrading.setCompare(target, compare); + }, + onTick: () => { + if (state.tornDown || !clock.isPlaying()) return; + const t = clock.now(); + state.currentTime = t; + seekTimelineAndAdapters(t); + if (clock.reachedEnd()) { + webAudio.stopAll(); + clock.detachAudioSource(); + clock.pause(); + state.isPlaying = false; + const dur = clock.getDuration(); + if (Number.isFinite(dur)) { + clock.seek(dur); + state.currentTime = dur; + seekTimelineAndAdapters(dur); + } + runAdapters("pause"); + syncMediaForCurrentState(); + postState(true); + } + }, + onEnablePickMode: () => picker.enablePickMode(), + onDisablePickMode: () => picker.disablePickMode(), + getCanonicalFps: () => state.canonicalFps, + }); + const teardown = () => { if (state.tornDown) return; state.tornDown = true;