diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 1e6481766..6b07478cf 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -7,6 +7,7 @@ function createMockDeps() { onPause: vi.fn(), onSeek: vi.fn(), onSetMuted: vi.fn(), + onSetMediaOutputMuted: vi.fn(), onSetPlaybackRate: vi.fn(), onEnablePickMode: vi.fn(), onDisablePickMode: vi.fn(), @@ -55,6 +56,22 @@ describe("installRuntimeControlBridge", () => { expect(deps.onSetMuted).toHaveBeenCalledWith(true); }); + it("dispatches set-media-output-muted command", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("set-media-output-muted", { muted: true })); + expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(true); + handler(makeControlMessage("set-media-output-muted", { muted: false })); + expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false); + }); + + it("set-media-output-muted coerces absent flag to false", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("set-media-output-muted")); + expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false); + }); + it("dispatches set-playback-rate command", () => { const deps = createMockDeps(); const handler = installRuntimeControlBridge(deps); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index 7afa4a27d..e11966a23 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -5,6 +5,7 @@ type BridgeDeps = { onPause: () => void; onSeek: (frame: number, seekMode: "drag" | "commit") => void; onSetMuted: (muted: boolean) => void; + onSetMediaOutputMuted: (muted: boolean) => void; onSetPlaybackRate: (rate: number) => void; onEnablePickMode: () => void; onDisablePickMode: () => void; @@ -39,6 +40,10 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv deps.onSetMuted(Boolean(data.muted)); return; } + if (action === "set-media-output-muted") { + deps.onSetMediaOutputMuted(Boolean(data.muted)); + return; + } if (action === "set-playback-rate") { deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)); return; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index ff82b291e..a4ba9552c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1189,6 +1189,12 @@ export function initSandboxRuntimeModular(): void { timeSeconds: state.currentTime, playing: state.isPlaying, playbackRate: state.playbackRate, + outputMuted: state.mediaOutputMuted, + onAutoplayBlocked: () => { + if (state.mediaAutoplayBlockedPosted) return; + state.mediaAutoplayBlockedPosted = true; + postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" }); + }, }); const rootCompId = document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null; @@ -1424,10 +1430,20 @@ export function initSandboxRuntimeModular(): void { }, onSetMuted: (muted) => { state.bridgeMuted = muted; + const effective = muted || state.mediaOutputMuted; + const mediaEls = document.querySelectorAll("video, audio"); + for (const el of mediaEls) { + if (!(el instanceof HTMLMediaElement)) continue; + el.muted = effective; + } + }, + onSetMediaOutputMuted: (muted) => { + state.mediaOutputMuted = muted; + const effective = muted || state.bridgeMuted; const mediaEls = document.querySelectorAll("video, audio"); for (const el of mediaEls) { if (!(el instanceof HTMLMediaElement)) continue; - el.muted = muted; + el.muted = effective; } }, onSetPlaybackRate: (rate) => applyPlaybackRate(rate), diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index c052ee6b0..f9d9a537f 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -381,4 +381,81 @@ describe("syncRuntimeMedia", () => { syncRuntimeMedia({ clips: [clip], timeSeconds: 7, playing: false, playbackRate: 1 }); expect(clip.el.currentTime).toBe(7); }); + + it("asserts muted=true every tick while outputMuted is set", () => { + // Parent ownership has taken over audible playback via parent-frame + // proxies. The iframe runtime must silence every active media element + // per tick so new sub-composition media inherits the mute as soon as + // it appears in the DOM — otherwise a late