fix(producer): mix muted browser media as silent for preview-render parity (#1969)

This commit is contained in:
Miguel Ángel
2026-07-06 23:33:50 -04:00
committed by GitHub
parent e9c37b5fdb
commit a2a80d5a5c
3 changed files with 76 additions and 2 deletions
@@ -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<BrowserMedia
loop: boolean;
hasAudio: boolean;
volume: number;
muted: boolean;
}[] = [];
const mediaEls = document.querySelectorAll("video[data-start], audio[data-start]");
@@ -1858,6 +1861,7 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
const loop = htmlEl.hasAttribute("loop");
const hasAudio = htmlEl.getAttribute("data-has-audio") === "true";
const volume = parseFloat(htmlEl.getAttribute("data-volume") || "1");
const muted = htmlEl.hasAttribute("muted") || htmlEl.muted;
results.push({
id,
@@ -1870,6 +1874,7 @@ export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMedia
loop,
hasAudio,
volume,
muted,
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test";
import { pruneMutedBrowserMedia } from "./probeStage.js";
describe("pruneMutedBrowserMedia", () => {
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);
});
});
@@ -375,8 +375,11 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
const existingVideoIds = new Set(composition.videos.map((v) => 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<ProbeStageR
) {
existing.mediaStart = el.mediaStart;
}
if (el.hasAudio && !existing.hasAudio) {
if (el.hasAudio && !el.muted && !existing.hasAudio) {
existing.hasAudio = true;
}
if (el.loop && !existing.loop) {
@@ -427,7 +430,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
end: el.end,
mediaStart: el.mediaStart,
loop: el.loop,
hasAudio: el.hasAudio,
hasAudio: el.hasAudio && !el.muted,
});
existingVideoIds.add(el.id);
}
@@ -610,3 +613,31 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
beginFrameStalled,
};
}
/**
* Preview/render parity for `muted` media: the runtime keeps muted elements
* silent, so the mixer must exclude their audio too (they used to be mixed at
* full volume). Muted video still renders frames but loses its audio track;
* muted audio drops out of the mix entirely. Pure over its inputs so the
* parity rule is testable without the probe-session harness.
*/
export function pruneMutedBrowserMedia(
composition: {
videos: { id: string; hasAudio?: boolean }[];
audios: { id: string }[];
},
browserMedia: { id: string; tagName: string; muted?: boolean }[],
existingAudioIds?: Set<string>,
): 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);
}
}
}