mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(producer): surface the reason when audio mixing fails instead of silently shipping video-only (#1854)
At least 4 independent post-release feedback reports of a render completing successfully (exit 0) with audio elements correctly authored and detected at compile time (audioCount > 0), but the final MP4 having no audio track — discovered only via ffprobe or manual playback, with the CLI giving no indication anything went wrong. Users worked around it by muxing the generated audio in manually with ffmpeg. Root cause: runAudioStage sets hasAudio from processCompositionAudio's success flag, but discarded its error field — the actual reason a per-element audio prep step or the final mix failed (source not found, extract failed, ffmpeg error) was computed and then thrown away. A real audio-mix failure was therefore indistinguishable from "no audio was authored": both just produced hasAudio: false with zero diagnostic output. Thread the mixer's error through as audioError (only set when audios.length > 0 but the mix failed) and log.warn it from both call sites (the main render path in renderOrchestrator.ts and the distributed plan() path) so a real failure is loud instead of silently downgrading to a video-only render. Tests: 4 new cases for runAudioStage (mixer error surfaced, generic fallback message when the mixer doesn't provide one, no audioError on success, no audioError when there's no audio to mix). renderOrchestrator.test.ts (68 tests) unaffected. plan.test.ts's one failure (an audio-bearing planHash determinism test timing out at 30s) is pre-existing — reproduces identically on unmodified main with these changes stashed.
This commit is contained in:
@@ -903,6 +903,9 @@ export async function plan(
|
|||||||
abortSignal,
|
abortSignal,
|
||||||
assertNotAborted,
|
assertNotAborted,
|
||||||
});
|
});
|
||||||
|
if (audioResult.audioError) {
|
||||||
|
log.warn(`[Render] Audio mix failed — output will be video-only: ${audioResult.audioError}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Promote staged artifacts from the temp work tree into the final planDir
|
// Promote staged artifacts from the temp work tree into the final planDir
|
||||||
// shape. `workDir` is `<planDir>/.plan-work/` — always the same filesystem
|
// shape. `workDir` is `<planDir>/.plan-work/` — always the same filesystem
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import type { AudioElement } from "@hyperframes/engine";
|
||||||
|
|
||||||
|
const { processCompositionAudioMock } = vi.hoisted(() => ({
|
||||||
|
processCompositionAudioMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@hyperframes/engine", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@hyperframes/engine")>();
|
||||||
|
return { ...actual, processCompositionAudio: processCompositionAudioMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { runAudioStage } from "./audioStage.js";
|
||||||
|
|
||||||
|
// Regression: hasAudio flipping to false used to be indistinguishable from
|
||||||
|
// "no audio was authored" — processCompositionAudio's error (per-element
|
||||||
|
// failures, or the mix's own failure) was read into hasAudio and then
|
||||||
|
// discarded, so a real audio-mix failure shipped a silent video-only render
|
||||||
|
// with no indication anything went wrong. audioError carries that reason.
|
||||||
|
describe("runAudioStage", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
const audios: AudioElement[] = [
|
||||||
|
{ id: "a1", src: "narration.wav", start: 0, end: 5, mediaStart: 0, volume: 1, type: "audio" },
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
processCompositionAudioMock.mockClear();
|
||||||
|
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeInput(overrides: Partial<Parameters<typeof runAudioStage>[0]> = {}) {
|
||||||
|
const workDir = mkdtempSync(join(tmpdir(), "hf-audiostage-"));
|
||||||
|
tempDirs.push(workDir);
|
||||||
|
return {
|
||||||
|
projectDir: workDir,
|
||||||
|
workDir,
|
||||||
|
compiledDir: join(workDir, "compiled"),
|
||||||
|
duration: 5,
|
||||||
|
audios,
|
||||||
|
abortSignal: undefined,
|
||||||
|
assertNotAborted: () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("surfaces the mixer's error as audioError when the mix fails", async () => {
|
||||||
|
processCompositionAudioMock.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
outputPath: "audio.aac",
|
||||||
|
durationMs: 1,
|
||||||
|
tracksProcessed: 0,
|
||||||
|
error: "Source not found: a1 (narration.wav)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAudioStage(makeInput());
|
||||||
|
|
||||||
|
expect(result.hasAudio).toBe(false);
|
||||||
|
expect(result.audioError).toBe("Source not found: a1 (narration.wav)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a generic message when the mixer fails without an error string", async () => {
|
||||||
|
processCompositionAudioMock.mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
outputPath: "audio.aac",
|
||||||
|
durationMs: 1,
|
||||||
|
tracksProcessed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAudioStage(makeInput());
|
||||||
|
|
||||||
|
expect(result.hasAudio).toBe(false);
|
||||||
|
expect(result.audioError).toBe("audio mix failed for an unknown reason");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set audioError when the mix succeeds", async () => {
|
||||||
|
processCompositionAudioMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
outputPath: "audio.aac",
|
||||||
|
durationMs: 1,
|
||||||
|
tracksProcessed: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runAudioStage(makeInput());
|
||||||
|
|
||||||
|
expect(result.hasAudio).toBe(true);
|
||||||
|
expect(result.audioError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set audioError when there is no audio to mix", async () => {
|
||||||
|
const result = await runAudioStage(makeInput({ audios: [] }));
|
||||||
|
|
||||||
|
expect(processCompositionAudioMock).not.toHaveBeenCalled();
|
||||||
|
expect(result.hasAudio).toBe(false);
|
||||||
|
expect(result.audioError).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,12 @@ export interface AudioStageResult {
|
|||||||
hasAudio: boolean;
|
hasAudio: boolean;
|
||||||
/** Wall-clock ms for the audio mix phase. Zero-elements path is near-zero but always set. */
|
/** Wall-clock ms for the audio mix phase. Zero-elements path is near-zero but always set. */
|
||||||
audioProcessMs: number;
|
audioProcessMs: number;
|
||||||
|
/**
|
||||||
|
* Set when `audios.length > 0` but the mix failed (`hasAudio` is `false`
|
||||||
|
* despite audio being expected) — the caller should surface this. `undefined`
|
||||||
|
* both when there was no audio to mix and when the mix succeeded.
|
||||||
|
*/
|
||||||
|
audioError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAudioStage(input: AudioStageInput): Promise<AudioStageResult> {
|
export async function runAudioStage(input: AudioStageInput): Promise<AudioStageResult> {
|
||||||
@@ -48,6 +54,7 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
|
|||||||
const stage3Start = Date.now();
|
const stage3Start = Date.now();
|
||||||
const audioOutputPath = join(workDir, "audio.aac");
|
const audioOutputPath = join(workDir, "audio.aac");
|
||||||
let hasAudio = false;
|
let hasAudio = false;
|
||||||
|
let audioError: string | undefined;
|
||||||
|
|
||||||
if (audios.length > 0) {
|
if (audios.length > 0) {
|
||||||
const audioResult = await processCompositionAudio(
|
const audioResult = await processCompositionAudio(
|
||||||
@@ -63,8 +70,13 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
|
|||||||
assertNotAborted();
|
assertNotAborted();
|
||||||
|
|
||||||
hasAudio = audioResult.success;
|
hasAudio = audioResult.success;
|
||||||
|
// processCompositionAudio's error (per-element failures or the mix's own
|
||||||
|
// error) used to be discarded here — the caller only saw hasAudio flip to
|
||||||
|
// false with no explanation, so a real audio failure looked identical to
|
||||||
|
// "no audio was authored" and shipped a silent video-only render.
|
||||||
|
if (!hasAudio) audioError = audioResult.error ?? "audio mix failed for an unknown reason";
|
||||||
}
|
}
|
||||||
const audioProcessMs = Date.now() - stage3Start;
|
const audioProcessMs = Date.now() - stage3Start;
|
||||||
|
|
||||||
return { audioOutputPath, hasAudio, audioProcessMs };
|
return { audioOutputPath, hasAudio, audioProcessMs, audioError };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1356,6 +1356,9 @@ export async function executeRenderJob(
|
|||||||
);
|
);
|
||||||
const { audioOutputPath, hasAudio } = audioResult;
|
const { audioOutputPath, hasAudio } = audioResult;
|
||||||
perfStages.audioProcessMs = audioResult.audioProcessMs;
|
perfStages.audioProcessMs = audioResult.audioProcessMs;
|
||||||
|
if (audioResult.audioError) {
|
||||||
|
log.warn(`[Render] Audio mix failed — output will be video-only: ${audioResult.audioError}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Stage 4: Frame capture ──────────────────────────────────────────
|
// ── Stage 4: Frame capture ──────────────────────────────────────────
|
||||||
const stage4Start = Date.now();
|
const stage4Start = Date.now();
|
||||||
|
|||||||
Reference in New Issue
Block a user