mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(producer): mix muted browser media as silent for preview-render parity (#1969)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user