mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user