mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +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:
@@ -198,8 +198,12 @@ export {
|
||||
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
||||
|
||||
export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js";
|
||||
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
|
||||
export type {
|
||||
AudioElement,
|
||||
AudioFailureReason,
|
||||
AudioFailureStage,
|
||||
AudioProcessingFailure,
|
||||
AudioTrack,
|
||||
AudioVolumeKeyframe,
|
||||
MixResult,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -10,10 +11,16 @@ import { tmpdir } from "node:os";
|
||||
// filter content synchronously, while the file still exists, into an
|
||||
// index-aligned side array (rather than re-reading it from disk after
|
||||
// processCompositionAudio resolves, by which point it's already gone).
|
||||
const { runFfmpegMock, capturedFilterScripts } = vi.hoisted(() => {
|
||||
const { runFfmpegMock, capturedFilterScripts, extractAudioMetadataMock } = vi.hoisted(() => {
|
||||
const capturedFilterScripts: string[] = [];
|
||||
return {
|
||||
capturedFilterScripts,
|
||||
extractAudioMetadataMock: vi.fn(async () => ({
|
||||
durationSeconds: 2,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
audioCodec: "aac",
|
||||
})),
|
||||
runFfmpegMock: vi.fn(async (args: string[]) => {
|
||||
const legacyIdx = args.indexOf("-filter_complex_script");
|
||||
const currentIdx = args.indexOf("-/filter_complex");
|
||||
@@ -29,9 +36,15 @@ const { runFfmpegMock, capturedFilterScripts } = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/runFfmpeg.js", () => ({
|
||||
runFfmpeg: runFfmpegMock,
|
||||
}));
|
||||
vi.mock("../utils/runFfmpeg.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/runFfmpeg.js")>();
|
||||
return { ...actual, runFfmpeg: runFfmpegMock };
|
||||
});
|
||||
|
||||
vi.mock("../utils/ffprobe.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/ffprobe.js")>();
|
||||
return { ...actual, extractAudioMetadata: extractAudioMetadataMock };
|
||||
});
|
||||
|
||||
import { parseAudioElements, processCompositionAudio } from "./audioMixer.js";
|
||||
|
||||
@@ -40,12 +53,68 @@ describe("processCompositionAudio", () => {
|
||||
|
||||
afterEach(() => {
|
||||
runFfmpegMock.mockClear();
|
||||
extractAudioMetadataMock.mockReset();
|
||||
extractAudioMetadataMock.mockResolvedValue({
|
||||
durationSeconds: 2,
|
||||
sampleRate: 48_000,
|
||||
channels: 2,
|
||||
audioCodec: "aac",
|
||||
});
|
||||
capturedFilterScripts.length = 0;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
message: "AbortError: ffprobe operation aborted",
|
||||
reason: "cancelled",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
},
|
||||
{
|
||||
message: "ffprobe timed out after inactivity deadline",
|
||||
reason: "ffmpeg_timeout",
|
||||
owner: "system",
|
||||
retryable: true,
|
||||
},
|
||||
] as const)("classifies probe failure '$reason' independently", async (expected) => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
tempDirs.push(baseDir, workDir);
|
||||
writeFileSync(join(baseDir, "voice.wav"), "stub");
|
||||
extractAudioMetadataMock.mockRejectedValueOnce(new Error(expected.message));
|
||||
|
||||
const result = await processCompositionAudio(
|
||||
[
|
||||
{
|
||||
id: "voice",
|
||||
src: "voice.wav",
|
||||
start: 0,
|
||||
end: 0,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: 1,
|
||||
type: "audio",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
workDir,
|
||||
join(baseDir, "out.m4a"),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: "probe",
|
||||
reason: expected.reason,
|
||||
owner: expected.owner,
|
||||
retryable: expected.retryable,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves muted tracks and uses unity master gain by default", async () => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
@@ -79,6 +148,8 @@ describe("processCompositionAudio", () => {
|
||||
|
||||
expect(filter).toContain("volume=0");
|
||||
expect(filter).toContain("[mixed]volume=1[out]");
|
||||
expect(filter).toContain("apad,atrim=0:2");
|
||||
expect(filter).not.toContain("whole_dur");
|
||||
expect(filter).not.toContain("normalize=");
|
||||
expect(filter).not.toContain("weights=");
|
||||
});
|
||||
@@ -157,7 +228,9 @@ describe("processCompositionAudio", () => {
|
||||
return {
|
||||
success: !isMissingCuePrepare,
|
||||
durationMs: 1,
|
||||
stderr: isMissingCuePrepare ? "Invalid data found when processing input" : "",
|
||||
stderr: isMissingCuePrepare
|
||||
? "https://media.example.test/private.wav?token=secret /tmp/hf/private secret.wav: Invalid data found when processing input"
|
||||
: "",
|
||||
exitCode: isMissingCuePrepare ? 1 : 0,
|
||||
};
|
||||
});
|
||||
@@ -194,10 +267,205 @@ describe("processCompositionAudio", () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.tracksProcessed).toBe(1);
|
||||
expect(result.error).toMatch(/Prepare failed: missing-cue/);
|
||||
expect(result.error).toContain("Invalid data found when processing input");
|
||||
expect(result.error).toContain("<redacted-url>");
|
||||
expect(result.error).toContain("<redacted-path>");
|
||||
expect(result.error).not.toContain("token=secret");
|
||||
expect(result.error).not.toContain("/tmp/hf/private");
|
||||
expect(result.error).not.toContain("secret.wav");
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: "prepare",
|
||||
reason: "invalid_media",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
elementId: "missing-cue",
|
||||
}),
|
||||
]);
|
||||
expect(runFfmpegMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves and classifies unsupported FFmpeg filter failures", async () => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
tempDirs.push(baseDir, workDir);
|
||||
writeFileSync(join(baseDir, "voice.wav"), "stub");
|
||||
|
||||
runFfmpegMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
terminationReason: "exit",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
durationMs: 1,
|
||||
stderr: "Error applying option 'whole_dur': Option not found",
|
||||
exitCode: 8,
|
||||
terminationReason: "exit",
|
||||
});
|
||||
|
||||
const result = await processCompositionAudio(
|
||||
[
|
||||
{
|
||||
id: "voice",
|
||||
src: "voice.wav",
|
||||
start: 0,
|
||||
end: 2,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: 1,
|
||||
type: "audio",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
workDir,
|
||||
join(baseDir, "out.m4a"),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Option not found");
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: "mix",
|
||||
reason: "ffmpeg_unsupported",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a sanitized FFmpeg spawn failure cause", async () => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
tempDirs.push(baseDir, workDir);
|
||||
writeFileSync(join(baseDir, "voice.wav"), "stub");
|
||||
|
||||
runFfmpegMock.mockResolvedValueOnce({
|
||||
success: false,
|
||||
durationMs: 1,
|
||||
stderr: "",
|
||||
exitCode: null,
|
||||
terminationReason: "spawn_error",
|
||||
error: new Error("spawn C:\\private\\ffmpeg.exe ENOENT"),
|
||||
});
|
||||
|
||||
const result = await processCompositionAudio(
|
||||
[
|
||||
{
|
||||
id: "voice",
|
||||
src: "voice.wav",
|
||||
start: 0,
|
||||
end: 2,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: 1,
|
||||
type: "audio",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
workDir,
|
||||
join(baseDir, "out.m4a"),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("ENOENT");
|
||||
expect(result.error).toContain("<redacted-path>");
|
||||
expect(result.error).not.toContain("C:\\private\\ffmpeg.exe");
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_unavailable",
|
||||
owner: "system",
|
||||
retryable: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps invalid data from producer-generated mix inputs system-owned", async () => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
tempDirs.push(baseDir, workDir);
|
||||
writeFileSync(join(baseDir, "voice.wav"), "stub");
|
||||
|
||||
runFfmpegMock
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
durationMs: 1,
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
terminationReason: "exit",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
durationMs: 1,
|
||||
stderr: "Invalid data found when processing input",
|
||||
exitCode: 1,
|
||||
terminationReason: "exit",
|
||||
});
|
||||
|
||||
const result = await processCompositionAudio(
|
||||
[
|
||||
{
|
||||
id: "voice",
|
||||
src: "voice.wav",
|
||||
start: 0,
|
||||
end: 2,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: 1,
|
||||
type: "audio",
|
||||
},
|
||||
],
|
||||
baseDir,
|
||||
workDir,
|
||||
join(baseDir, "out.m4a"),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.failures).toEqual([
|
||||
expect.objectContaining({
|
||||
stage: "mix",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds per-cause details and the aggregate error across many authored IDs", async () => {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
|
||||
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
|
||||
tempDirs.push(baseDir, workDir);
|
||||
const oversizedId = "authored-id-".repeat(300);
|
||||
|
||||
const result = await processCompositionAudio(
|
||||
Array.from({ length: 3 }, (_, index) => ({
|
||||
id: `${oversizedId}-${index}`,
|
||||
src: `missing-${index}.wav`,
|
||||
start: 0,
|
||||
end: 2,
|
||||
mediaStart: 0,
|
||||
layer: index,
|
||||
volume: 1,
|
||||
type: "audio" as const,
|
||||
})),
|
||||
baseDir,
|
||||
workDir,
|
||||
join(baseDir, "out.m4a"),
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.length).toBeLessThanOrEqual(2_000);
|
||||
expect(result.failures).toHaveLength(3);
|
||||
expect(result.failures?.every((failure) => failure.detail.length <= 2_000)).toBe(true);
|
||||
});
|
||||
|
||||
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-"));
|
||||
@@ -402,7 +670,10 @@ describe("processCompositionAudio", () => {
|
||||
|
||||
const filter = capturedFilterScripts.at(-1);
|
||||
expect(filter).toContain(`amix=inputs=${trackCount}`);
|
||||
expect((filter?.match(/atrim=/g) ?? []).length).toBe(trackCount);
|
||||
// Each track is trimmed once to its authored clip and once after portable
|
||||
// indefinite `apad` to cap the padded stream at composition duration.
|
||||
expect((filter?.match(/atrim=/g) ?? []).length).toBe(trackCount * 2);
|
||||
expect((filter?.match(/apad,/g) ?? []).length).toBe(trackCount);
|
||||
});
|
||||
|
||||
it("retries with the current file-valued filter option when a nightly removes the legacy alias", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file complexity code-duplication
|
||||
/**
|
||||
* Audio Mixer Service
|
||||
*
|
||||
@@ -10,11 +11,17 @@ import { parseHTML } from "linkedom";
|
||||
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
||||
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
|
||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
|
||||
import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js";
|
||||
import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
|
||||
import type {
|
||||
AudioElement,
|
||||
AudioFailureStage,
|
||||
AudioProcessingFailure,
|
||||
AudioTrack,
|
||||
MixResult,
|
||||
} from "./audioMixer.types.js";
|
||||
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
||||
|
||||
export type { AudioElement, MixResult } from "./audioMixer.types.js";
|
||||
@@ -190,6 +197,108 @@ interface ExtractResult {
|
||||
outputPath: string;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
failure?: AudioProcessingFailure;
|
||||
}
|
||||
|
||||
function boundedDetail(message: string, maxLength = 2_000): string {
|
||||
const redacted = message
|
||||
.replace(/\bhttps?:\/\/[^\s"'<>]+/gi, "<redacted-url>")
|
||||
.replace(/\bfile:\/\/[^\s"'<>]+/gi, "<redacted-path>")
|
||||
.replace(
|
||||
/\b[A-Za-z]:[\\/].+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm,
|
||||
"<redacted-path>",
|
||||
)
|
||||
.replace(
|
||||
/(^|[\s"'(])\/.+?(?=:\s[A-Z]|\s(?:ENOENT|EACCES|EPERM)\b|\r?$)/gm,
|
||||
"$1<redacted-path>",
|
||||
);
|
||||
return redacted.length <= maxLength ? redacted : `${redacted.slice(0, maxLength - 1)}…`;
|
||||
}
|
||||
|
||||
function probeFailure(message: string, elementId: string): AudioProcessingFailure {
|
||||
const unavailable = /(?:not found|ENOENT|spawn)/i.test(message);
|
||||
const cancelled = /(?:aborted|AbortError|cancelled|canceled)/i.test(message);
|
||||
const timedOut = /(?:timed?\s*out|timeout|deadline|inactivity)/i.test(message);
|
||||
const invalidMedia =
|
||||
/(?:invalid data found|could not find codec parameters|moov atom not found|no audio stream)/i.test(
|
||||
message,
|
||||
);
|
||||
return {
|
||||
stage: "probe",
|
||||
reason: cancelled
|
||||
? "cancelled"
|
||||
: invalidMedia
|
||||
? "invalid_media"
|
||||
: unavailable
|
||||
? "ffmpeg_unavailable"
|
||||
: timedOut
|
||||
? "ffmpeg_timeout"
|
||||
: "probe_failed",
|
||||
owner: cancelled || invalidMedia ? "user" : "system",
|
||||
retryable: !cancelled && !invalidMedia && (unavailable || timedOut),
|
||||
elementId,
|
||||
detail: boundedDetail(`Audio probe failed for element ${elementId}: ${message}`),
|
||||
};
|
||||
}
|
||||
|
||||
function downloadFailure(message: string, elementId: string): AudioProcessingFailure {
|
||||
const invalidSource =
|
||||
/(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
|
||||
message,
|
||||
);
|
||||
return {
|
||||
stage: "download",
|
||||
reason: "download_failed",
|
||||
owner: invalidSource ? "user" : "system",
|
||||
retryable: !invalidSource,
|
||||
elementId,
|
||||
detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
|
||||
};
|
||||
}
|
||||
|
||||
function ffmpegFailure(
|
||||
stage: Extract<AudioFailureStage, "extract" | "prepare" | "mix" | "silence">,
|
||||
result: RunFfmpegResult,
|
||||
elementId?: string,
|
||||
): AudioProcessingFailure {
|
||||
const stderr = result.stderr ?? "";
|
||||
let reason: AudioProcessingFailure["reason"] = "ffmpeg_failed";
|
||||
let owner: AudioProcessingFailure["owner"] = "system";
|
||||
let retryable = false;
|
||||
|
||||
if (result.terminationReason === "abort") {
|
||||
reason = "cancelled";
|
||||
owner = "user";
|
||||
} else if (result.terminationReason === "deadline" || result.terminationReason === "inactivity") {
|
||||
reason = "ffmpeg_timeout";
|
||||
retryable = true;
|
||||
} else if (result.terminationReason === "spawn_error") {
|
||||
reason = "ffmpeg_unavailable";
|
||||
retryable = true;
|
||||
} else if (
|
||||
/(?:unrecognized option|option (?:was )?not found|no option name near)/i.test(stderr)
|
||||
) {
|
||||
reason = "ffmpeg_unsupported";
|
||||
} else if (
|
||||
(stage === "extract" || stage === "prepare") &&
|
||||
/(?:invalid data found|could not find codec parameters|moov atom not found)/i.test(stderr)
|
||||
) {
|
||||
reason = "invalid_media";
|
||||
owner = "user";
|
||||
}
|
||||
|
||||
return {
|
||||
stage,
|
||||
reason,
|
||||
owner,
|
||||
retryable,
|
||||
elementId,
|
||||
detail: boundedDetail(
|
||||
result.error?.message
|
||||
? `${formatFfmpegError(result.exitCode, stderr)}: ${result.error.message}`
|
||||
: formatFfmpegError(result.exitCode, stderr),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAudioElements(html: string): AudioElement[] {
|
||||
@@ -266,20 +375,29 @@ async function extractAudioFromVideo(
|
||||
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
const failure: AudioProcessingFailure = {
|
||||
stage: "cancelled",
|
||||
reason: "cancelled",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
detail: "Audio extract cancelled",
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "Audio extract cancelled",
|
||||
error: failure.detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
if (!result.success) {
|
||||
const failure = ffmpegFailure("extract", result);
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error:
|
||||
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
||||
error: failure.detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
return { success: true, outputPath, durationMs: result.durationMs };
|
||||
@@ -317,22 +435,28 @@ async function prepareAudioTrack(
|
||||
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
const failure: AudioProcessingFailure = {
|
||||
stage: "cancelled",
|
||||
reason: "cancelled",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
detail: "Audio prepare cancelled",
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "Audio prepare cancelled",
|
||||
error: failure.detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
const failure = !result.success ? ffmpegFailure("prepare", result) : undefined;
|
||||
return {
|
||||
success: result.success,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: !result.success
|
||||
? result.exitCode !== null
|
||||
? `FFmpeg exited with code ${result.exitCode}: ${result.stderr.slice(-200)}`
|
||||
: result.stderr
|
||||
: undefined,
|
||||
error: failure?.detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,22 +486,28 @@ async function generateSilence(
|
||||
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
||||
|
||||
if (signal?.aborted) {
|
||||
const failure: AudioProcessingFailure = {
|
||||
stage: "cancelled",
|
||||
reason: "cancelled",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
detail: "Silence generation cancelled",
|
||||
};
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: "Silence generation cancelled",
|
||||
error: failure.detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
const failure = !result.success ? ffmpegFailure("silence", result) : undefined;
|
||||
return {
|
||||
success: result.success,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
error: !result.success
|
||||
? result.exitCode !== null
|
||||
? `FFmpeg exited with code ${result.exitCode}`
|
||||
: result.stderr
|
||||
: undefined,
|
||||
error: failure?.detail,
|
||||
failure,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -399,6 +529,7 @@ async function mixAudioTracks(
|
||||
durationMs: result.durationMs,
|
||||
tracksProcessed: 0,
|
||||
error: result.error,
|
||||
failures: result.failure ? [result.failure] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -412,7 +543,7 @@ async function mixAudioTracks(
|
||||
const trimDuration = track.end - track.start;
|
||||
const volumeFilter = buildVolumeExpression(track, ignoreAutomation);
|
||||
filterParts.push(
|
||||
`[${i}:a]atrim=0:${trimDuration},${volumeFilter},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
|
||||
`[${i}:a]atrim=0:${trimDuration},${volumeFilter},adelay=${delayMs}|${delayMs},apad,atrim=0:${formatFilterNumber(totalDuration)}[a${i}]`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -499,16 +630,26 @@ async function mixAudioTracks(
|
||||
durationMs: result.durationMs,
|
||||
tracksProcessed: 0,
|
||||
error: "Audio mix cancelled",
|
||||
failures: [
|
||||
{
|
||||
stage: "cancelled",
|
||||
reason: "cancelled",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
detail: "Audio mix cancelled",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!result.success) {
|
||||
const failure = ffmpegFailure("mix", result);
|
||||
return {
|
||||
success: false,
|
||||
outputPath,
|
||||
durationMs: result.durationMs,
|
||||
tracksProcessed: 0,
|
||||
error:
|
||||
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
||||
error: failure.detail,
|
||||
failures: [failure],
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -534,14 +675,21 @@ export async function processCompositionAudio(
|
||||
): Promise<MixResult> {
|
||||
const startMs = Date.now();
|
||||
const tracks: AudioTrack[] = [];
|
||||
const errors: string[] = [];
|
||||
const failures: AudioProcessingFailure[] = [];
|
||||
|
||||
if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
|
||||
|
||||
await Promise.all(
|
||||
elements.map(async (element) => {
|
||||
if (signal?.aborted) {
|
||||
errors.push(`Cancelled: ${element.id}`);
|
||||
failures.push({
|
||||
stage: "cancelled",
|
||||
reason: "cancelled",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
elementId: element.id,
|
||||
detail: boundedDetail(`Cancelled audio element ${element.id}`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -556,21 +704,36 @@ export async function processCompositionAudio(
|
||||
try {
|
||||
srcPath = await downloadToTemp(srcPath, workDir);
|
||||
} catch (err: unknown) {
|
||||
errors.push(
|
||||
`Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`,
|
||||
failures.push(
|
||||
downloadFailure(err instanceof Error ? err.message : String(err), element.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(srcPath)) {
|
||||
errors.push(`Source not found: ${element.id} (${element.src})`);
|
||||
failures.push({
|
||||
stage: "source",
|
||||
reason: "source_not_found",
|
||||
owner: "user",
|
||||
retryable: false,
|
||||
elementId: element.id,
|
||||
detail: boundedDetail(`Source not found for audio element ${element.id}`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: if no duration was specified, probe the actual file
|
||||
if (element.end - element.start <= 0) {
|
||||
const metadata = await extractAudioMetadata(srcPath);
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await extractAudioMetadata(srcPath);
|
||||
} catch (err: unknown) {
|
||||
failures.push(
|
||||
probeFailure(err instanceof Error ? err.message : String(err), element.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const effectiveDuration = metadata.durationSeconds - element.mediaStart;
|
||||
element.end =
|
||||
element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
|
||||
@@ -590,7 +753,18 @@ export async function processCompositionAudio(
|
||||
config,
|
||||
);
|
||||
if (!extractResult.success) {
|
||||
errors.push(`Extract failed: ${element.id}`);
|
||||
failures.push(
|
||||
extractResult.failure
|
||||
? { ...extractResult.failure, elementId: element.id }
|
||||
: {
|
||||
stage: "extract",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
elementId: element.id,
|
||||
detail: boundedDetail(`Audio extract failed for element ${element.id}`),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
audioSrcPath = extractedPath;
|
||||
@@ -605,7 +779,18 @@ export async function processCompositionAudio(
|
||||
config,
|
||||
);
|
||||
if (!prepResult.success) {
|
||||
errors.push(`Prepare failed: ${element.id}`);
|
||||
failures.push(
|
||||
prepResult.failure
|
||||
? { ...prepResult.failure, elementId: element.id }
|
||||
: {
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
elementId: element.id,
|
||||
detail: boundedDetail(`Audio prepare failed for element ${element.id}`),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
audioSrcPath = trimmedPath;
|
||||
@@ -636,7 +821,18 @@ export async function processCompositionAudio(
|
||||
volumeKeyframes: bakedEnvelope ? undefined : element.volumeKeyframes,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
errors.push(`Error: ${element.id} — ${err instanceof Error ? err.message : String(err)}`);
|
||||
failures.push({
|
||||
stage: "internal",
|
||||
reason: "internal",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
elementId: element.id,
|
||||
detail: boundedDetail(
|
||||
`Audio processing failed for element ${element.id}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -645,7 +841,7 @@ export async function processCompositionAudio(
|
||||
// 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) {
|
||||
if (failures.length > 0) {
|
||||
try {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
@@ -656,7 +852,10 @@ export async function processCompositionAudio(
|
||||
outputPath,
|
||||
durationMs: Date.now() - startMs,
|
||||
tracksProcessed: tracks.length,
|
||||
error: `Audio processing failed: ${errors.join(", ")}`,
|
||||
error: boundedDetail(
|
||||
`Audio processing failed: ${failures.map((failure) => failure.detail).join(", ")}`,
|
||||
),
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -671,6 +870,6 @@ export async function processCompositionAudio(
|
||||
return {
|
||||
...mixResult,
|
||||
durationMs: Date.now() - startMs,
|
||||
error: errors.length > 0 ? `Warnings: ${errors.join(", ")}` : mixResult.error,
|
||||
error: mixResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,10 +26,44 @@ export interface AudioTrack {
|
||||
volumeKeyframes?: AudioVolumeKeyframe[];
|
||||
}
|
||||
|
||||
export type AudioFailureStage =
|
||||
| "source"
|
||||
| "download"
|
||||
| "probe"
|
||||
| "extract"
|
||||
| "prepare"
|
||||
| "mix"
|
||||
| "silence"
|
||||
| "cancelled"
|
||||
| "internal";
|
||||
|
||||
export type AudioFailureReason =
|
||||
| "source_not_found"
|
||||
| "download_failed"
|
||||
| "probe_failed"
|
||||
| "invalid_media"
|
||||
| "ffmpeg_unsupported"
|
||||
| "ffmpeg_timeout"
|
||||
| "ffmpeg_unavailable"
|
||||
| "ffmpeg_failed"
|
||||
| "cancelled"
|
||||
| "internal";
|
||||
|
||||
export interface AudioProcessingFailure {
|
||||
stage: AudioFailureStage;
|
||||
reason: AudioFailureReason;
|
||||
owner: "user" | "system";
|
||||
retryable: boolean;
|
||||
elementId?: string;
|
||||
/** Bounded diagnostic text; never includes the authored source URL/path. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface MixResult {
|
||||
success: boolean;
|
||||
outputPath: string;
|
||||
durationMs: number;
|
||||
tracksProcessed: number;
|
||||
error?: string;
|
||||
failures?: AudioProcessingFailure[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cloneCaptureWarning } from "./captureWarning.js";
|
||||
|
||||
describe("cloneCaptureWarning", () => {
|
||||
it("deep-clones every mutable warning-detail array", () => {
|
||||
const source = {
|
||||
code: "audio_processing_failed" as const,
|
||||
message: "audio failed",
|
||||
details: {
|
||||
sources: ["voice.wav"],
|
||||
failureReasons: ["ffmpeg_timeout"],
|
||||
failureStages: ["prepare"],
|
||||
},
|
||||
};
|
||||
|
||||
const clone = cloneCaptureWarning(source);
|
||||
clone.details.sources.push("clone-source.wav");
|
||||
clone.details.failureReasons.push("clone-reason");
|
||||
clone.details.failureStages.push("clone-stage");
|
||||
|
||||
expect(source.details).toEqual({
|
||||
sources: ["voice.wav"],
|
||||
failureReasons: ["ffmpeg_timeout"],
|
||||
failureStages: ["prepare"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CaptureWarning } from "../types.js";
|
||||
|
||||
/** Clone every mutable field before a warning crosses an async or ownership boundary. */
|
||||
export function cloneCaptureWarning<T extends CaptureWarning>(warning: T): T {
|
||||
return {
|
||||
...warning,
|
||||
details: warning.details
|
||||
? {
|
||||
...warning.details,
|
||||
sources: warning.details.sources ? [...warning.details.sources] : undefined,
|
||||
failureReasons: warning.details.failureReasons
|
||||
? [...warning.details.failureReasons]
|
||||
: undefined,
|
||||
failureStages: warning.details.failureStages
|
||||
? [...warning.details.failureStages]
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
} as T;
|
||||
}
|
||||
|
||||
export function cloneCaptureWarnings<T extends CaptureWarning>(warnings: readonly T[]): T[] {
|
||||
return warnings.map(cloneCaptureWarning);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import type {
|
||||
CaptureWarning,
|
||||
SubTimelineWaitOutcome,
|
||||
} from "../types.js";
|
||||
import { cloneCaptureWarnings } from "./captureWarning.js";
|
||||
export { isMemoryExhaustionError, isTransientBrowserError } from "./captureFailure.js";
|
||||
|
||||
export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
|
||||
@@ -3773,15 +3774,7 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
|
||||
p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95),
|
||||
p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99),
|
||||
subTimelineWaitOutcome: session.subTimelineWaitOutcome,
|
||||
warnings: session.warnings.map((warning) => ({
|
||||
...warning,
|
||||
details: warning.details
|
||||
? {
|
||||
...warning.details,
|
||||
sources: warning.details.sources ? [...warning.details.sources] : undefined,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
warnings: cloneCaptureWarnings(session.warnings),
|
||||
staticDedupReused: session.staticDedupCount ?? 0,
|
||||
staticDedupEnabled: session.staticDedupEnabled ?? false,
|
||||
// armed ⟺ a non-empty static set survived verification; predicted === its size.
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface CaptureWarning {
|
||||
mediaType?: "image" | "video" | "audio";
|
||||
sources?: string[];
|
||||
timeoutMs?: number;
|
||||
failureReasons?: string[];
|
||||
failureStages?: string[];
|
||||
failureOwner?: "user" | "system";
|
||||
retryable?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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