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

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

* fix(audio): address failure taxonomy review
This commit is contained in:
James Russo
2026-07-25 17:12:31 -04:00
committed by GitHub
parent 72e2f08f15
commit 37b88688e7
18 changed files with 839 additions and 84 deletions
+278 -7
View File
@@ -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 () => {
+231 -32
View File
@@ -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);
}
+2 -9
View File
@@ -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.