Skip to content

Commit 9b3f89a

Browse files
vanceingallsclaude
andcommitted
fix(producer): classify the audio failure that takes the whole mix down
`runAudioStage` catches the rejection `processCompositionAudio` throws when an FX failure cannot be degraded past — and returned `audioFailures: undefined` with it. `applyDistributedAudioWarningPolicy` reads owner, retryability, reason and stage off that list, so the FATAL failure was reported with an empty reasons array, an empty stages array and no owner: strictly less classification than a single dropped track gets, which is the opposite of what the stage's own comment says it exists to do. A failure is now synthesised from the error. "internal" is the honest bucket — the stage names enumerate ffmpeg steps and this is the FX render, which is none of them — and system/non-retryable is right for a browser or chain that will not build. The detail is bounded at 2000 characters like the mixer's own. Falsified by restoring `audioFailures: undefined`: both assertions fail. Producer's suite has 50 pre-existing collect failures under vitest (files that import `bun:test`); passing goes 533 -> 534, unchanged otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a858ff9 commit 9b3f89a

2 files changed

Lines changed: 39 additions & 3 deletions

File tree

packages/producer/src/services/render/stages/audioStage.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,27 @@ describe("runAudioStage", () => {
122122
const result = await runAudioStage(makeInput());
123123
expect(result.hasAudio).toBe(false);
124124
expect(result.audioError).toMatch(/Audio FX failed for track bgm/);
125-
expect(result.audioFailures).toBeUndefined();
125+
// And it is classified. This used to come back undefined, so the warning
126+
// policy — which reads owner, retryability, reason and stage off this list
127+
// — described the FATAL failure with strictly less detail than a single
128+
// dropped track gets.
129+
expect(result.audioFailures).toEqual([
130+
{
131+
stage: "internal",
132+
reason: "internal",
133+
owner: "system",
134+
retryable: false,
135+
detail: "Audio FX failed for track bgm: browser launch failed",
136+
},
137+
]);
138+
});
139+
140+
it("bounds the synthesised failure's detail", async () => {
141+
// `detail` is contractually bounded diagnostic text; an ffmpeg-flavoured
142+
// message can run to tens of kilobytes.
143+
processCompositionAudioMock.mockRejectedValue(new Error("x".repeat(5_000)));
144+
const result = await runAudioStage(makeInput());
145+
expect(result.audioFailures?.[0]?.detail.length).toBe(2_000);
126146
});
127147

128148
it("lets an abort keep its own shape rather than becoming an audio error", async () => {

packages/producer/src/services/render/stages/audioStage.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,28 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
8484
} catch (err) {
8585
// An abort is the caller's own signal and must keep its own shape.
8686
assertNotAborted();
87+
const detail = err instanceof Error ? err.message : String(err);
8788
return {
8889
audioOutputPath,
8990
hasAudio: false,
9091
audioProcessMs: Date.now() - stage3Start,
91-
audioError: err instanceof Error ? err.message : String(err),
92-
audioFailures: undefined,
92+
audioError: detail,
93+
// Synthesised rather than left undefined. The warning policy reads
94+
// owner, retryability, reason and stage off this list, so reporting the
95+
// FATAL failure — the one that took the whole mix down — with an empty
96+
// one gave it strictly less classification than a single dropped track
97+
// gets, which is the opposite of what this stage exists to do.
98+
// "internal" is the honest bucket: the stages enumerate ffmpeg steps
99+
// and this is the FX render, which is none of them.
100+
audioFailures: [
101+
{
102+
stage: "internal",
103+
reason: "internal",
104+
owner: "system",
105+
retryable: false,
106+
detail: detail.slice(0, 2_000),
107+
},
108+
],
93109
};
94110
}
95111
assertNotAborted();

0 commit comments

Comments
 (0)