From a2a80d5a5c8649a0ef79b89047ebf3debe42dfd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 6 Jul 2026 23:33:50 -0400 Subject: [PATCH] fix(producer): mix muted browser media as silent for preview-render parity (#1969) --- .../producer/src/services/htmlCompiler.ts | 5 +++ .../render/stages/mutedParity.test.ts | 38 +++++++++++++++++++ .../src/services/render/stages/probeStage.ts | 35 ++++++++++++++++- 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 packages/producer/src/services/render/stages/mutedParity.test.ts diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 39a10a974..05a8ce3c6 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -1822,6 +1822,8 @@ export interface BrowserMediaElement { loop: boolean; hasAudio: boolean; volume: number; + /** The `muted` attribute/property. Preview silences muted media; the mix must too. */ + muted: boolean; } export interface BrowserAudioVolumeAutomation { @@ -1842,6 +1844,7 @@ export async function discoverMediaFromBrowser(page: Page): Promise { + it("drops muted audio from the mix and clears hasAudio on muted video", () => { + const composition = { + videos: [ + { id: "clip", hasAudio: true }, + { id: "other", hasAudio: true }, + ], + audios: [{ id: "bgm" }, { id: "voice" }], + }; + const ids = new Set(["bgm", "voice"]); + pruneMutedBrowserMedia( + composition, + [ + { id: "clip", tagName: "video", muted: true }, + { id: "voice", tagName: "audio", muted: true }, + { id: "bgm", tagName: "audio", muted: false }, + ], + ids, + ); + expect(composition.audios.map((a) => a.id)).toEqual(["bgm"]); + expect(ids.has("voice")).toBe(false); + expect(composition.videos.find((v) => v.id === "clip")?.hasAudio).toBe(false); + expect(composition.videos.find((v) => v.id === "other")?.hasAudio).toBe(true); + }); + + it("is a no-op when nothing is muted", () => { + const composition = { videos: [{ id: "v", hasAudio: true }], audios: [{ id: "a" }] }; + pruneMutedBrowserMedia(composition, [ + { id: "v", tagName: "video", muted: false }, + { id: "a", tagName: "audio" }, + ]); + expect(composition.audios.length).toBe(1); + expect(composition.videos[0]?.hasAudio).toBe(true); + }); +}); diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts index 297543b8f..273c96013 100644 --- a/packages/producer/src/services/render/stages/probeStage.ts +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -375,8 +375,11 @@ export async function runProbeStage(input: ProbeStageInput): Promise v.id)); const existingAudioIds = new Set(composition.audios.map((a) => a.id)); + pruneMutedBrowserMedia(composition, browserMedia, existingAudioIds); + for (const el of browserMedia) { if (!el.src || el.src === "about:blank") continue; + if (el.muted && el.tagName === "audio") continue; // Convert absolute localhost URLs back to relative paths let src = el.src; @@ -411,7 +414,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise, +): void { + for (const el of browserMedia) { + if (!el.muted) continue; + if (el.tagName === "video") { + const existing = composition.videos.find((v) => v.id === el.id); + if (existing) existing.hasAudio = false; + } else { + const idx = composition.audios.findIndex((a) => a.id === el.id); + if (idx >= 0) composition.audios.splice(idx, 1); + existingAudioIds?.delete(el.id); + } + } +}