mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(engine): preserve authored audio mix order (#5672)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { runFfmpegMock } = vi.hoisted(() => ({ runFfmpegMock: vi.fn() }));
|
||||
vi.mock("../utils/runFfmpeg.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../utils/runFfmpeg.js")>()),
|
||||
runFfmpeg: runFfmpegMock,
|
||||
}));
|
||||
vi.mock("../utils/ffprobe.js", () => ({
|
||||
extractAudioMetadata: vi.fn(async () => ({ durationSeconds: 1, channels: 2 })),
|
||||
}));
|
||||
|
||||
import { processCompositionAudio, type AudioElement } from "./audioMixer.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
runFfmpegMock.mockReset();
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(["flat", "one bus", "two buses"])(
|
||||
"keeps authored mix input order when preparations finish in reverse (%s)",
|
||||
async (layout) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-audio-order-"));
|
||||
dirs.push(dir);
|
||||
const ids =
|
||||
layout === "two buses"
|
||||
? ["first", "second", "third", "fourth"]
|
||||
: ["first", "second", "third"];
|
||||
const elements: AudioElement[] = ids.map((id, index) => {
|
||||
writeFileSync(join(dir, `${id}.wav`), "RIFF0000WAVE");
|
||||
return {
|
||||
id,
|
||||
src: `${id}.wav`,
|
||||
type: "audio",
|
||||
start: 0,
|
||||
end: 1,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: 1,
|
||||
...(layout === "flat"
|
||||
? {}
|
||||
: { groupId: layout === "one bus" ? "bus" : `bus-${index % 2}`, groupVolume: 1 }),
|
||||
};
|
||||
});
|
||||
const pending = new Map<string, () => void>();
|
||||
const completed: string[] = [];
|
||||
const mixes: string[][] = [];
|
||||
let allStarted = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
allStarted = resolve;
|
||||
});
|
||||
runFfmpegMock.mockImplementation(async (args: string[]) => {
|
||||
const out = args.at(-1)!;
|
||||
if (out.endsWith("-trimmed.wav")) {
|
||||
await new Promise<void>((resolve) => {
|
||||
pending.set(basename(out), resolve);
|
||||
if (pending.size === elements.length) allStarted();
|
||||
});
|
||||
completed.push(basename(out));
|
||||
} else {
|
||||
mixes.push(args.flatMap((arg, i) => (arg === "-i" ? [basename(args[i + 1]!)] : [])));
|
||||
}
|
||||
writeFileSync(out, "RIFF0000WAVE");
|
||||
return { success: true, durationMs: 1, stderr: "", exitCode: 0 };
|
||||
});
|
||||
const result = processCompositionAudio(
|
||||
elements,
|
||||
dir,
|
||||
join(dir, "work"),
|
||||
join(dir, "out.m4a"),
|
||||
1,
|
||||
);
|
||||
await started;
|
||||
const reversed = [...ids].reverse();
|
||||
for (const id of reversed) {
|
||||
pending.get(`${id}-trimmed.wav`)!();
|
||||
await setImmediate();
|
||||
}
|
||||
expect((await result).success).toBe(true);
|
||||
expect(completed).toEqual(reversed.map((id) => `${id}-trimmed.wav`));
|
||||
if (layout === "two buses") {
|
||||
expect(mixes).toEqual([
|
||||
["first-trimmed.wav", "third-trimmed.wav"],
|
||||
["second-trimmed.wav", "fourth-trimmed.wav"],
|
||||
["group-bus-0-0.wav", "group-bus-1-1.wav"],
|
||||
]);
|
||||
} else {
|
||||
expect(mixes[0]).toEqual(["first-trimmed.wav", "second-trimmed.wav", "third-trimmed.wav"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1123,6 +1123,7 @@ export async function processCompositionAudio(
|
||||
): Promise<MixResult> {
|
||||
const startMs = Date.now();
|
||||
const tracks: AudioTrack[] = [];
|
||||
const preparedTracks = new Array<AudioTrack | undefined>(elements.length);
|
||||
const failures: AudioProcessingFailure[] = [];
|
||||
// Grouped members are diverted here instead of `tracks` during element
|
||||
// processing, then summed into one processed track per group before the
|
||||
@@ -1154,7 +1155,7 @@ export async function processCompositionAudio(
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
elements.map(async (element) => {
|
||||
elements.map(async (element, index) => {
|
||||
if (effectiveSignal.aborted) {
|
||||
failures.push({
|
||||
stage: "cancelled",
|
||||
@@ -1386,24 +1387,7 @@ export async function processCompositionAudio(
|
||||
...(tailSeconds > 0 ? { tailSeconds } : {}),
|
||||
};
|
||||
|
||||
// A grouped member keeps every bit of its OWN processing above
|
||||
// (FX, envelope) exactly like an ungrouped track — it just lands in
|
||||
// the group's bucket instead of the flat list, to be summed into one
|
||||
// processed bus below rather than mixed directly.
|
||||
if (element.groupId) {
|
||||
const bucket = groupTracks.get(element.groupId);
|
||||
if (bucket) bucket.push(track);
|
||||
else groupTracks.set(element.groupId, [track]);
|
||||
if (!groupMeta.has(element.groupId)) {
|
||||
groupMeta.set(element.groupId, {
|
||||
...(element.groupFxChain ? { fxChain: element.groupFxChain } : {}),
|
||||
...(element.groupAutomation ? { automation: element.groupAutomation } : {}),
|
||||
volume: element.groupVolume ?? 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tracks.push(track);
|
||||
}
|
||||
preparedTracks[index] = track;
|
||||
} catch (err: unknown) {
|
||||
// An FX failure is fatal for the whole mix. Every other failure mode
|
||||
// degrades gracefully — the track drops and siblings continue — but
|
||||
@@ -1436,6 +1420,27 @@ export async function processCompositionAudio(
|
||||
throw err;
|
||||
});
|
||||
|
||||
// FFmpeg's floating-point sum depends on input order. Keep parallel prep,
|
||||
// but assemble flat tracks, group members and groups in authored order.
|
||||
elements.forEach((element, index) => {
|
||||
const track = preparedTracks[index];
|
||||
if (!track) return;
|
||||
if (element.groupId) {
|
||||
const bucket = groupTracks.get(element.groupId);
|
||||
if (bucket) bucket.push(track);
|
||||
else groupTracks.set(element.groupId, [track]);
|
||||
if (!groupMeta.has(element.groupId)) {
|
||||
groupMeta.set(element.groupId, {
|
||||
...(element.groupFxChain ? { fxChain: element.groupFxChain } : {}),
|
||||
...(element.groupAutomation ? { automation: element.groupAutomation } : {}),
|
||||
volume: element.groupVolume ?? 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
tracks.push(track);
|
||||
}
|
||||
});
|
||||
|
||||
// Never turn a per-track preparation failure into a successful partial mix.
|
||||
// The producer only surfaces audio failures when `success` is false; mixing
|
||||
// the remaining tracks made the omitted cue indistinguishable from a valid
|
||||
|
||||
Reference in New Issue
Block a user