fix(audio): preserve causes and use portable padding (#2769)

* fix(audio): preserve causes and use portable padding

* fix(audio): address failure taxonomy review
This commit is contained in:
James Russo
2026-07-25 17:12:31 -04:00
committed by GitHub
parent 72e2f08f15
commit 37b88688e7
18 changed files with 839 additions and 84 deletions
@@ -207,7 +207,7 @@ async function mixTracks(
const trimDuration = track.duration > 0 ? track.duration : totalDuration;
filterParts.push(
`[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
`[${i}:a]atrim=0:${trimDuration},volume=${track.volume},adelay=${delayMs}|${delayMs},apad,atrim=0:${totalDuration}[a${i}]`,
);
});
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
/**
* Unit tests for `services/distributed/plan.ts`.
*
@@ -76,8 +77,63 @@ describe("distributed warning policy", () => {
it("rejects distributed audio degradation in best-effort mode", () => {
const job = createJob("best-effort");
expect(() => applyDistributedAudioWarningPolicy(job, "mix failed")).toThrow(RenderQualityError);
expect(() =>
applyDistributedAudioWarningPolicy(job, "mix failed", [
{
stage: "mix",
reason: "ffmpeg_unsupported",
owner: "system",
retryable: false,
detail: "Option not found",
},
]),
).toThrow(RenderQualityError);
expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]);
expect(job.warnings[0]?.details).toEqual(
expect.objectContaining({
failureReasons: ["ffmpeg_unsupported"],
failureStages: ["mix"],
failureOwner: "system",
retryable: false,
}),
);
});
it("only marks a multi-cause audio failure retryable when every cause is retryable", () => {
const job = createJob("best-effort");
expect(() =>
applyDistributedAudioWarningPolicy(job, "mixed failure", [
{
stage: "download",
reason: "download_failed",
owner: "system",
retryable: true,
detail: "temporary download failure",
},
{
stage: "prepare",
reason: "invalid_media",
owner: "user",
retryable: false,
detail: "invalid media",
},
]),
).toThrow(RenderQualityError);
expect(job.warnings[0]?.details).toEqual(
expect.objectContaining({
failureOwner: "system",
retryable: false,
}),
);
});
it("does not invent ownership or retryability for legacy untyped failures", () => {
const job = createJob("best-effort");
expect(() => applyDistributedAudioWarningPolicy(job, "legacy failure")).toThrow(
RenderQualityError,
);
expect(job.warnings[0]?.details?.failureOwner).toBeUndefined();
expect(job.warnings[0]?.details?.retryable).toBeUndefined();
});
});
@@ -42,6 +42,7 @@ import {
getEncoderPreset,
normalizeVp9CpuUsed,
resolveConfig,
type AudioProcessingFailure,
} from "@hyperframes/engine";
import { defaultLogger, type ProducerLogger } from "../../logger.js";
import {
@@ -269,15 +270,30 @@ export interface PlanResult {
export function applyDistributedAudioWarningPolicy(
job: RenderJob,
audioError: string,
audioFailures: readonly AudioProcessingFailure[] = [],
log: ProducerLogger = defaultLogger,
): void {
const failureOwner =
audioFailures.length === 0
? undefined
: audioFailures.some((failure) => failure.owner === "system")
? "system"
: "user";
const retryable =
audioFailures.length === 0 ? undefined : audioFailures.every((failure) => failure.retryable);
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: `Audio mix failed; output would be video-only: ${audioError}`,
details: { mediaType: "audio" },
details: {
mediaType: "audio",
failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))],
failureStages: [...new Set(audioFailures.map((failure) => failure.stage))],
failureOwner,
retryable,
},
},
],
log,
@@ -943,7 +959,7 @@ export async function plan(
assertNotAborted,
});
if (audioResult.audioError) {
applyDistributedAudioWarningPolicy(job, audioResult.audioError, log);
applyDistributedAudioWarningPolicy(job, audioResult.audioError, audioResult.audioFailures, log);
}
// Promote staged artifacts from the temp work tree into the final planDir
@@ -246,7 +246,7 @@ function concatFileLine(path: string): string {
// `///C:/…`, which Windows path parsing then rejects). Field-
// signal reports ts=1784169914 / 1784177061 / 1784177375 (all
// win32/x64 CLI 0.7.59; the last isolated the module's arg shape
// vs a working manual `apad=whole_dur` command).
// vs a working manual pad/trim command).
// 2. Bare `/tmp/…` when the concat script was fed via `pipe:0` —
// FFmpeg's URL joiner resolves absolute POSIX paths against the
// base `pipe:` URL, producing `pipe:/tmp/…` which the demuxer
@@ -33,6 +33,38 @@ describe("OrderedRenderEventPublisher", () => {
]);
});
it("deep-clones typed warning arrays across the progress sink boundary", async () => {
const deliveredReasons: string[][] = [];
const publisher = new OrderedRenderEventPublisher(
async (snapshot) => {
const reasons = snapshot.warnings[0]?.details?.failureReasons;
if (reasons) {
deliveredReasons.push([...reasons]);
reasons.push("sink_mutation");
}
},
{ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
);
const job = createRenderJob({ fps: 30, quality: "high" });
job.warnings.push({
code: "audio_processing_failed",
message: "audio failed",
stage: "capture-readiness",
details: {
mediaType: "audio",
failureReasons: ["ffmpeg_timeout"],
failureStages: ["prepare"],
},
});
publisher.publish(job, "warning");
job.warnings[0]!.details!.failureReasons!.push("source_mutation");
await publisher.flush();
expect(deliveredReasons).toEqual([["ffmpeg_timeout"]]);
expect(job.warnings[0]?.details?.failureReasons).toEqual(["ffmpeg_timeout", "source_mutation"]);
});
it("contains sink rejection and still delivers the terminal event", async () => {
const delivered: number[] = [];
const warn = vi.fn();
@@ -147,6 +179,71 @@ describe("updateJobStatus", () => {
).toThrow(RenderQualityError);
expect(job.config.strictness).toBe("best-effort");
expect(job.warnings).toHaveLength(1);
expect(log.warn).toHaveBeenCalledWith(
"Render completed capture with correctness warnings",
expect.objectContaining({ warningRetryable: undefined }),
);
});
it("logs bounded audio failure taxonomy without requiring raw stderr", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "raw diagnostic stays on the warning object",
details: {
mediaType: "audio",
failureReasons: ["ffmpeg_timeout"],
failureStages: ["prepare"],
failureOwner: "system",
retryable: true,
},
},
],
log,
),
).toThrow(RenderQualityError);
expect(log.warn).toHaveBeenCalledWith(
"Render completed capture with correctness warnings",
expect.objectContaining({
warningCodes: ["audio_processing_failed"],
warningReasons: ["ffmpeg_timeout"],
warningStages: ["prepare"],
warningOwners: ["system"],
warningRetryable: true,
}),
);
});
it("only logs an aggregate warning as retryable when every typed warning is retryable", () => {
const job = createRenderJob({ fps: 30, quality: "high" });
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
expect(() =>
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: "temporary audio failure",
details: { mediaType: "audio", retryable: true },
},
{
code: "media_load_failed",
message: "terminal media failure",
details: { mediaType: "video", retryable: false },
},
],
log,
),
).toThrow(RenderQualityError);
expect(log.warn).toHaveBeenCalledWith(
"Render completed capture with correctness warnings",
expect.objectContaining({ warningRetryable: false }),
);
});
it("fails explicitly strict renders on correctness warnings", () => {
@@ -1,3 +1,4 @@
import { cloneCaptureWarnings } from "@hyperframes/engine";
import type { ProducerLogger } from "../../logger.js";
import type { ProgressCallback, RenderJob } from "../renderOrchestrator.js";
import { updateJobStatus } from "./shared.js";
@@ -5,15 +6,7 @@ import { updateJobStatus } from "./shared.js";
function snapshotJob(job: RenderJob): RenderJob {
return {
...job,
warnings: job.warnings.map((warning) => ({
...warning,
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
})),
warnings: cloneCaptureWarnings(job.warnings),
};
}
@@ -53,12 +53,25 @@ describe("runAudioStage", () => {
durationMs: 1,
tracksProcessed: 0,
error: "Source not found: a1 (narration.wav)",
failures: [
{
stage: "source",
reason: "source_not_found",
owner: "user",
retryable: false,
elementId: "a1",
detail: "Source not found for audio element a1",
},
],
});
const result = await runAudioStage(makeInput());
expect(result.hasAudio).toBe(false);
expect(result.audioError).toBe("Source not found: a1 (narration.wav)");
expect(result.audioFailures).toEqual([
expect.objectContaining({ reason: "source_not_found", stage: "source" }),
]);
});
it("falls back to a generic message when the mixer fails without an error string", async () => {
@@ -95,5 +108,6 @@ describe("runAudioStage", () => {
expect(processCompositionAudioMock).not.toHaveBeenCalled();
expect(result.hasAudio).toBe(false);
expect(result.audioError).toBeUndefined();
expect(result.audioFailures).toBeUndefined();
});
});
@@ -16,7 +16,7 @@
*/
import { join } from "node:path";
import { processCompositionAudio } from "@hyperframes/engine";
import { processCompositionAudio, type AudioProcessingFailure } from "@hyperframes/engine";
import type { CompositionMetadata } from "../shared.js";
export interface AudioStageInput {
@@ -45,6 +45,8 @@ export interface AudioStageResult {
* both when there was no audio to mix and when the mix succeeded.
*/
audioError?: string;
/** Bounded typed causes for policy, telemetry, and caller classification. */
audioFailures?: AudioProcessingFailure[];
}
export async function runAudioStage(input: AudioStageInput): Promise<AudioStageResult> {
@@ -55,6 +57,7 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
const audioOutputPath = join(workDir, "audio.aac");
let hasAudio = false;
let audioError: string | undefined;
let audioFailures: AudioProcessingFailure[] | undefined;
if (audios.length > 0) {
const audioResult = await processCompositionAudio(
@@ -70,6 +73,7 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
assertNotAborted();
hasAudio = audioResult.success;
audioFailures = audioResult.failures;
// 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
@@ -78,5 +82,11 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
}
const audioProcessMs = Date.now() - stage3Start;
return { audioOutputPath, hasAudio, audioProcessMs, audioError };
return {
audioOutputPath,
hasAudio,
audioProcessMs,
audioError,
audioFailures,
};
}
@@ -44,6 +44,7 @@ import {
type HdrTransfer,
type StreamingEncoder,
closeCaptureSession,
cloneCaptureWarnings,
createCaptureSession,
getEncoderPreset,
initTransparentBackground,
@@ -128,18 +129,6 @@ export interface CaptureHdrStageResult {
warnings: CaptureWarning[];
}
function cloneCaptureWarnings(warnings: readonly CaptureWarning[]): CaptureWarning[] {
return warnings.map((warning) => ({
...warning,
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
}));
}
export async function runCaptureHdrStage(
input: CaptureHdrStageInput,
): Promise<CaptureHdrStageResult> {
@@ -82,6 +82,7 @@ import {
applyConcreteGpuScreenshotClamp,
scaleProtocolTimeoutForComposition,
classifyCaptureFailure,
cloneCaptureWarning,
isMemoryExhaustionError,
isDrawElementVerificationError,
getDrawElementVerificationDetails,
@@ -613,22 +614,28 @@ export function applyRenderWarningPolicy(
if (existing.has(key)) continue;
existing.add(key);
job.warnings.push({
...warning,
...cloneCaptureWarning(warning),
stage: "capture-readiness",
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
});
}
if (job.warnings.length === 0) return;
const strictness = job.config.strictness ?? "best-effort";
const typedRetryability = job.warnings.flatMap((warning) =>
warning.details?.retryable === undefined ? [] : [warning.details.retryable],
);
log.warn("Render completed capture with correctness warnings", {
strictness,
warningCodes: job.warnings.map((warning) => warning.code),
warningReasons: job.warnings.flatMap((warning) => warning.details?.failureReasons ?? []),
warningStages: job.warnings.flatMap((warning) => warning.details?.failureStages ?? []),
warningOwners: job.warnings.flatMap((warning) =>
warning.details?.failureOwner ? [warning.details.failureOwner] : [],
),
warningRetryable:
typedRetryability.length === 0
? undefined
: typedRetryability.every((retryable) => retryable),
});
const hasAudioProcessingFailure = job.warnings.some(
(warning) => warning.code === "audio_processing_failed",
@@ -2184,13 +2191,30 @@ async function executeRenderPipeline(input: {
const { audioOutputPath, hasAudio } = audioResult;
perfStages.audioProcessMs = audioResult.audioProcessMs;
if (audioResult.audioError) {
const audioFailures = audioResult.audioFailures ?? [];
const failureOwner =
audioFailures.length === 0
? undefined
: audioFailures.some((failure) => failure.owner === "system")
? "system"
: "user";
const retryable =
audioFailures.length === 0
? undefined
: audioFailures.every((failure) => failure.retryable);
applyRenderWarningPolicy(
job,
[
{
code: "audio_processing_failed",
message: `Audio mix failed; output would be video-only: ${audioResult.audioError}`,
details: { mediaType: "audio" },
details: {
mediaType: "audio",
failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))],
failureStages: [...new Set(audioFailures.map((failure) => failure.stage))],
failureOwner,
retryable,
},
},
],
log,