diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index eb4c64fc1..2f5f5d344 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -903,6 +903,9 @@ export async function plan( abortSignal, 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 // shape. `workDir` is `/.plan-work/` — always the same filesystem diff --git a/packages/producer/src/services/render/stages/audioStage.test.ts b/packages/producer/src/services/render/stages/audioStage.test.ts new file mode 100644 index 000000000..1f218a2f6 --- /dev/null +++ b/packages/producer/src/services/render/stages/audioStage.test.ts @@ -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(); + 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[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(); + }); +}); diff --git a/packages/producer/src/services/render/stages/audioStage.ts b/packages/producer/src/services/render/stages/audioStage.ts index fca171212..488a6e99a 100644 --- a/packages/producer/src/services/render/stages/audioStage.ts +++ b/packages/producer/src/services/render/stages/audioStage.ts @@ -39,6 +39,12 @@ export interface AudioStageResult { hasAudio: boolean; /** Wall-clock ms for the audio mix phase. Zero-elements path is near-zero but always set. */ 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 { @@ -48,6 +54,7 @@ export async function runAudioStage(input: AudioStageInput): Promise 0) { const audioResult = await processCompositionAudio( @@ -63,8 +70,13 @@ export async function runAudioStage(input: AudioStageInput): Promise