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
34 changes: 34 additions & 0 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ function createMockDeps() {
onSetMuted: vi.fn(),
onSetVolume: vi.fn(),
onSetMediaOutputMuted: vi.fn(),
onSetNativeMediaSyncDisabled: vi.fn(),
onSetWebAudioMediaDisabled: vi.fn(),
onSetPlaybackRate: vi.fn(),
onSetColorGrading: vi.fn(),
onSetColorGradingCompare: vi.fn(),
Expand Down Expand Up @@ -107,6 +109,38 @@ describe("installRuntimeControlBridge", () => {
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
});

it("dispatches set-native-media-sync-disabled command", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-native-media-sync-disabled", { disabled: true }));
expect(deps.onSetNativeMediaSyncDisabled).toHaveBeenCalledWith(true);
handler(makeControlMessage("set-native-media-sync-disabled", { disabled: false }));
expect(deps.onSetNativeMediaSyncDisabled).toHaveBeenCalledWith(false);
});

it("set-native-media-sync-disabled coerces absent flag to false", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-native-media-sync-disabled"));
expect(deps.onSetNativeMediaSyncDisabled).toHaveBeenCalledWith(false);
});

it("dispatches set-web-audio-media-disabled command", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-web-audio-media-disabled", { disabled: true }));
expect(deps.onSetWebAudioMediaDisabled).toHaveBeenCalledWith(true);
handler(makeControlMessage("set-web-audio-media-disabled", { disabled: false }));
expect(deps.onSetWebAudioMediaDisabled).toHaveBeenCalledWith(false);
});

it("set-web-audio-media-disabled coerces absent flag to false", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-web-audio-media-disabled"));
expect(deps.onSetWebAudioMediaDisabled).toHaveBeenCalledWith(false);
});

it("dispatches set-playback-rate command", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type BridgeDeps = {
onSetMuted: (muted: boolean) => void;
onSetVolume: (volume: number) => void;
onSetMediaOutputMuted: (muted: boolean) => void;
onSetNativeMediaSyncDisabled: (disabled: boolean) => void;
onSetWebAudioMediaDisabled: (disabled: boolean) => void;
onSetPlaybackRate: (rate: number) => void;
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
onSetColorGradingCompare: (
Expand Down Expand Up @@ -46,6 +48,10 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
"set-volume": (data, deps) =>
deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1)))),
"set-media-output-muted": (data, deps) => deps.onSetMediaOutputMuted(Boolean(data.muted)),
"set-native-media-sync-disabled": (data, deps) =>
deps.onSetNativeMediaSyncDisabled(Boolean(data.disabled)),
"set-web-audio-media-disabled": (data, deps) =>
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
"set-color-grading": (data, deps) =>
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,57 @@ describe("initSandboxRuntimeModular", () => {
expect(audio.muted).toBe(false);
});

it("native media sync opt-out leaves user-started media playing while timeline is paused", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "root");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "10");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);

const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "10");
audio.setAttribute("src", "voiceover.mp3");
Object.defineProperty(audio, "duration", { value: 10, configurable: true });
Object.defineProperty(audio, "readyState", {
value: HTMLMediaElement.HAVE_FUTURE_DATA,
configurable: true,
});
Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true });
Object.defineProperty(audio, "paused", { value: true, writable: true, configurable: true });
audio.pause = vi.fn(() => {
Object.defineProperty(audio, "paused", {
value: true,
writable: true,
configurable: true,
});
});
root.appendChild(audio);

window.__timelines = { root: createMockTimeline(10) };
initSandboxRuntimeModular();

window.dispatchEvent(
new MessageEvent("message", {
data: {
source: "hf-parent",
type: "control",
action: "set-native-media-sync-disabled",
disabled: true,
},
}),
);
Object.defineProperty(audio, "paused", { value: false, writable: true, configurable: true });
vi.mocked(audio.pause).mockClear();

window.__player?.renderSeek(5);

expect(audio.pause).not.toHaveBeenCalled();
});

it("skips the per-frame transport re-seek while a Studio manual-edit gesture is active", () => {
const raf = createManualRaf();
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
Expand Down
82 changes: 62 additions & 20 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1501,23 +1501,27 @@ export function initSandboxRuntimeModular(): void {

const forceSync = state.mediaForceSyncNextTick;
if (forceSync) state.mediaForceSyncNextTick = false;
syncRuntimeMedia({
clips: cache.mediaClips,
timeSeconds: state.currentTime,
playing: state.isPlaying,
playbackRate: state.playbackRate,
outputMuted: state.mediaOutputMuted,
userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume,
forceSync,
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
},
});
if (!state.nativeMediaSyncDisabled) {
syncRuntimeMedia({
clips: cache.mediaClips,
timeSeconds: state.currentTime,
playing: state.isPlaying,
playbackRate: state.playbackRate,
outputMuted:
state.mediaOutputMuted ||
(!state.webAudioMediaDisabled && !state.nativeMediaSyncDisabled && webAudio.isActive()),
userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume,
forceSync,
onElementVolume: (el, volume) => webAudio.setElementVolume(el, volume),
isWebAudioOwned: (el) => webAudio.ownsElement(el),
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
},
});
}
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
const rootComp = resolveRootCompositionElement();
for (const rawNode of visibilityNodes) {
Expand Down Expand Up @@ -1882,6 +1886,29 @@ export function initSandboxRuntimeModular(): void {
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);
Expand Down Expand Up @@ -2201,7 +2228,12 @@ export function initSandboxRuntimeModular(): void {
// 2. HTMLMediaElement (audio.currentTime): ~33ms, frame-accurate
// 3. Monotonic (performance.now()): ~1ms, no audio coupling
if (clock.isPlaying() && !state.mediaOutputMuted) {
if (webAudio.isActive() && webAudio.context) {
if (
!state.nativeMediaSyncDisabled &&
!state.webAudioMediaDisabled &&
webAudio.isActive() &&
webAudio.context
) {
const webAudioTime = webAudio.getTime();
if (webAudioTime >= 0) {
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
Expand Down Expand Up @@ -2313,6 +2345,7 @@ export function initSandboxRuntimeModular(): void {
// same edge as the HTMLMedia path. Reused by play() and by the rate-change
// handler (a rate change can't rescale a bounded source in place).
const scheduleWebAudioForActiveClips = () => {
if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return;
const gen = webAudio.startGeneration();
const audioEls = document.querySelectorAll("audio[data-start]");
for (const rawEl of audioEls) {
Expand Down Expand Up @@ -2362,7 +2395,14 @@ export function initSandboxRuntimeModular(): void {
// stopAll()+reschedule at the new rate to keep trimmed clips ending on time.
const applyWebAudioRate = () => {
const changed = webAudio.setRate(state.playbackRate);
if (changed && webAudioReady && clock.isPlaying() && webAudio.hasBoundedActiveSources()) {
if (
changed &&
!state.nativeMediaSyncDisabled &&
!state.webAudioMediaDisabled &&
webAudioReady &&
clock.isPlaying() &&
webAudio.hasBoundedActiveSources()
) {
webAudio.stopAll();
scheduleWebAudioForActiveClips();
}
Expand Down Expand Up @@ -2392,7 +2432,9 @@ export function initSandboxRuntimeModular(): void {
// 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) scheduleWebAudioForActiveClips();
if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) {
scheduleWebAudioForActiveClips();
}
runAdapters("play");
syncMediaForCurrentState();
colorGrading.redraw();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/runtime/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ describe("createRuntimeState", () => {
expect(state.canonicalFps).toBe(30);
expect(state.playbackRate).toBe(1);
expect(state.bridgeMuted).toBe(false);
expect(state.nativeMediaSyncDisabled).toBe(false);
expect(state.webAudioMediaDisabled).toBe(false);
expect(state.capturedTimeline).toBeNull();
expect(state.tornDown).toBe(false);
});
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/runtime/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ export type RuntimeState = {
* accuracy but produces no audio of its own.
*/
mediaOutputMuted: boolean;
/**
* Disable runtime ownership of native media elements. Slideshow presenter
* mode keeps the slide timeline paused while users interact with embedded
* media, so the runtime must not auto-play, auto-pause, seek, or volume-sync
* those native elements on every transport tick.
*/
nativeMediaSyncDisabled: boolean;
/**
* Disable the runtime's WebAudio replacement for native <audio> elements.
* Slideshow presenter/audience windows mirror native media element events
* across browsers, so muting those elements for WebAudio ownership breaks
* audible presenter playback and remote sync.
*/
webAudioMediaDisabled: boolean;
/**
* Latch so the `media-autoplay-blocked` outbound message is posted at most
* once per runtime session. The parent only needs the first signal — it
Expand Down Expand Up @@ -88,6 +102,8 @@ export function createRuntimeState(): RuntimeState {
bridgeMuted: false,
bridgeVolume: 1,
mediaOutputMuted: false,
nativeMediaSyncDisabled: false,
webAudioMediaDisabled: false,
mediaAutoplayBlockedPosted: false,
mediaForceSyncNextTick: false,
playbackRate: 1,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type RuntimeBridgeControlAction =
| "tick"
| "set-volume"
| "set-media-output-muted"
| "set-native-media-sync-disabled"
| "set-web-audio-media-disabled"
| "stop-media"
| "flash-elements";

Expand All @@ -26,6 +28,7 @@ export type RuntimeBridgeControlMessage = {
frame?: number;
muted?: boolean;
volume?: number;
disabled?: boolean;
playbackRate?: number;
target?: HfColorGradingTarget | string | null;
grading?: RuntimeJson;
Expand Down
80 changes: 80 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,52 @@ describe("HyperframesPlayer parent-frame media", () => {
expect(mockAudio.pause).toHaveBeenCalled();
});

function dispatchAutoplayBlockedFromPlayerFrame(player: HTMLElement): HTMLMediaElement {
const iframe = player.shadowRoot?.querySelector("iframe");
if (!(iframe instanceof HTMLIFrameElement)) throw new Error("expected player iframe");
const iframeDoc = iframe.contentDocument;
if (!iframeDoc) throw new Error("expected player iframe document");
const video = iframeDoc.createElement("video");
video.setAttribute("data-start", "0");
video.setAttribute("data-duration", "10");
video.muted = false;
iframeDoc.body.appendChild(video);

window.dispatchEvent(
new MessageEvent("message", {
source: iframe.contentWindow,
data: { source: "hf-preview", type: "media-autoplay-blocked" },
}),
);

return video;
}

it("does not mute iframe media on autoplay fallback inside presenter slideshow", () => {
const slideshow = document.createElement("hyperframes-slideshow");
slideshow.appendChild(player);
document.body.appendChild(slideshow);

const video = dispatchAutoplayBlockedFromPlayerFrame(player);

expect(video.muted).toBe(false);
expect(player._audioOwner).toBe("runtime");
slideshow.remove();
});

it("does not promote autoplay fallback inside audience slideshow", () => {
const slideshow = document.createElement("hyperframes-slideshow");
slideshow.setAttribute("mode", "audience");
slideshow.appendChild(player);
document.body.appendChild(slideshow);

const video = dispatchAutoplayBlockedFromPlayerFrame(player);

expect(video.muted).toBe(false);
expect(player._audioOwner).toBe("runtime");
slideshow.remove();
});

it("seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)", () => {
// Regression: previously `seek()` only called `seekAll()`, leaving the
// proxy playing. With the timeline frozen at the new seek target, the
Expand Down Expand Up @@ -1834,6 +1880,40 @@ describe("HyperframesPlayer runtime ready handshake", () => {
});
});

it("keeps runtime WebAudio media enabled outside slideshow embeds", () => {
postSpy.mockClear();

player._onMessage(readyMessage());

expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
action: "set-native-media-sync-disabled",
disabled: false,
});
expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
action: "set-web-audio-media-disabled",
disabled: false,
});
});

it("disables runtime WebAudio media inside slideshow embeds", () => {
const slideshow = document.createElement("hyperframes-slideshow");
slideshow.appendChild(player);
document.body.appendChild(slideshow);
postSpy.mockClear();

player._onMessage(readyMessage());

expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
action: "set-native-media-sync-disabled",
disabled: true,
});
expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
action: "set-web-audio-media-disabled",
disabled: true,
});
slideshow.remove();
});

it("replays the muted state forced by audio-locked", () => {
// The audio-locked attribute is the original motivating case for this
// handshake — its `muted = true` side effect must survive an iframe race.
Expand Down
Loading
Loading