From 968c90397b3e7852367fa3741c9fc44c3472814d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 15 Jul 2026 15:26:42 -0400 Subject: [PATCH] fix(engine): fail partial audio track preparation (#2488) --- .../engine/src/services/audioMixer.test.ts | 55 +++++++++++++++++++ packages/engine/src/services/audioMixer.ts | 19 +++++++ 2 files changed, 74 insertions(+) diff --git a/packages/engine/src/services/audioMixer.test.ts b/packages/engine/src/services/audioMixer.test.ts index 999bc3228..3ac56fdbc 100644 --- a/packages/engine/src/services/audioMixer.test.ts +++ b/packages/engine/src/services/audioMixer.test.ts @@ -143,6 +143,61 @@ describe("processCompositionAudio", () => { expect(filter).toContain("[mixed]volume=3[out]"); }); + it("fails the audio result instead of silently mixing after one track preparation fails", async () => { + const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); + const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); + tempDirs.push(baseDir, workDir); + + writeFileSync(join(baseDir, "working.wav"), "stub"); + writeFileSync(join(baseDir, "missing-cue.wav"), "stub"); + + const defaultImplementation = runFfmpegMock.getMockImplementation()!; + runFfmpegMock.mockImplementation(async (args: string[]) => { + const isMissingCuePrepare = args.includes(join(baseDir, "missing-cue.wav")); + return { + success: !isMissingCuePrepare, + durationMs: 1, + stderr: isMissingCuePrepare ? "Invalid data found when processing input" : "", + exitCode: isMissingCuePrepare ? 1 : 0, + }; + }); + + const result = await processCompositionAudio( + [ + { + id: "working", + src: "working.wav", + start: 0, + end: 0.5, + mediaStart: 0, + layer: 0, + volume: 1, + type: "audio", + }, + { + id: "missing-cue", + src: "missing-cue.wav", + start: 3.859, + end: 4.359, + mediaStart: 0, + layer: 1, + volume: 1, + type: "audio", + }, + ], + baseDir, + workDir, + join(baseDir, "out.m4a"), + 5, + ); + runFfmpegMock.mockImplementation(defaultImplementation); + + expect(result.success).toBe(false); + expect(result.tracksProcessed).toBe(1); + expect(result.error).toMatch(/Prepare failed: missing-cue/); + expect(runFfmpegMock).toHaveBeenCalledTimes(2); + }); + it("uses frame-evaluated volume automation when keyframes are present", async () => { const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); diff --git a/packages/engine/src/services/audioMixer.ts b/packages/engine/src/services/audioMixer.ts index a6439912b..4f2122598 100644 --- a/packages/engine/src/services/audioMixer.ts +++ b/packages/engine/src/services/audioMixer.ts @@ -625,6 +625,25 @@ export async function processCompositionAudio( }), ); + // 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 + // render unless someone manually audited that exact audio window. + if (errors.length > 0) { + try { + rmSync(workDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + return { + success: false, + outputPath, + durationMs: Date.now() - startMs, + tracksProcessed: tracks.length, + error: `Audio processing failed: ${errors.join(", ")}`, + }; + } + const mixResult = await mixAudioTracks(tracks, outputPath, totalDuration, signal, config); try {