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