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
+4
View File
@@ -198,8 +198,12 @@ export {
export { createVideoFrameInjector } from "./services/videoFrameInjector.js"; export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js"; export { parseAudioElements, processCompositionAudio } from "./services/audioMixer.js";
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
export type { export type {
AudioElement, AudioElement,
AudioFailureReason,
AudioFailureStage,
AudioProcessingFailure,
AudioTrack, AudioTrack,
AudioVolumeKeyframe, AudioVolumeKeyframe,
MixResult, MixResult,
+278 -7
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@@ -10,10 +11,16 @@ import { tmpdir } from "node:os";
// filter content synchronously, while the file still exists, into an // filter content synchronously, while the file still exists, into an
// index-aligned side array (rather than re-reading it from disk after // index-aligned side array (rather than re-reading it from disk after
// processCompositionAudio resolves, by which point it's already gone). // processCompositionAudio resolves, by which point it's already gone).
const { runFfmpegMock, capturedFilterScripts } = vi.hoisted(() => { const { runFfmpegMock, capturedFilterScripts, extractAudioMetadataMock } = vi.hoisted(() => {
const capturedFilterScripts: string[] = []; const capturedFilterScripts: string[] = [];
return { return {
capturedFilterScripts, capturedFilterScripts,
extractAudioMetadataMock: vi.fn(async () => ({
durationSeconds: 2,
sampleRate: 48_000,
channels: 2,
audioCodec: "aac",
})),
runFfmpegMock: vi.fn(async (args: string[]) => { runFfmpegMock: vi.fn(async (args: string[]) => {
const legacyIdx = args.indexOf("-filter_complex_script"); const legacyIdx = args.indexOf("-filter_complex_script");
const currentIdx = args.indexOf("-/filter_complex"); const currentIdx = args.indexOf("-/filter_complex");
@@ -29,9 +36,15 @@ const { runFfmpegMock, capturedFilterScripts } = vi.hoisted(() => {
}; };
}); });
vi.mock("../utils/runFfmpeg.js", () => ({ vi.mock("../utils/runFfmpeg.js", async (importOriginal) => {
runFfmpeg: runFfmpegMock, 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"; import { parseAudioElements, processCompositionAudio } from "./audioMixer.js";
@@ -40,12 +53,68 @@ describe("processCompositionAudio", () => {
afterEach(() => { afterEach(() => {
runFfmpegMock.mockClear(); runFfmpegMock.mockClear();
extractAudioMetadataMock.mockReset();
extractAudioMetadataMock.mockResolvedValue({
durationSeconds: 2,
sampleRate: 48_000,
channels: 2,
audioCodec: "aac",
});
capturedFilterScripts.length = 0; capturedFilterScripts.length = 0;
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true }); 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 () => { it("preserves muted tracks and uses unity master gain by default", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
@@ -79,6 +148,8 @@ describe("processCompositionAudio", () => {
expect(filter).toContain("volume=0"); expect(filter).toContain("volume=0");
expect(filter).toContain("[mixed]volume=1[out]"); 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("normalize=");
expect(filter).not.toContain("weights="); expect(filter).not.toContain("weights=");
}); });
@@ -157,7 +228,9 @@ describe("processCompositionAudio", () => {
return { return {
success: !isMissingCuePrepare, success: !isMissingCuePrepare,
durationMs: 1, 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, exitCode: isMissingCuePrepare ? 1 : 0,
}; };
}); });
@@ -194,10 +267,205 @@ describe("processCompositionAudio", () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.tracksProcessed).toBe(1); 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); 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 () => { it("uses frame-evaluated volume automation when keyframes are present", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-")); const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-")); const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
@@ -402,7 +670,10 @@ describe("processCompositionAudio", () => {
const filter = capturedFilterScripts.at(-1); const filter = capturedFilterScripts.at(-1);
expect(filter).toContain(`amix=inputs=${trackCount}`); 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 () => { 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 * Audio Mixer Service
* *
@@ -10,11 +11,17 @@ import { parseHTML } from "linkedom";
import { extractAudioMetadata } from "../utils/ffprobe.js"; import { extractAudioMetadata } from "../utils/ffprobe.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.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 { unwrapTemplate } from "../utils/htmlTemplate.js";
import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js"; import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.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"; import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
export type { AudioElement, MixResult } from "./audioMixer.types.js"; export type { AudioElement, MixResult } from "./audioMixer.types.js";
@@ -190,6 +197,108 @@ interface ExtractResult {
outputPath: string; outputPath: string;
durationMs: number; durationMs: number;
error?: string; 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[] { export function parseAudioElements(html: string): AudioElement[] {
@@ -266,20 +375,29 @@ async function extractAudioFromVideo(
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout }); const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (signal?.aborted) { if (signal?.aborted) {
const failure: AudioProcessingFailure = {
stage: "cancelled",
reason: "cancelled",
owner: "user",
retryable: false,
detail: "Audio extract cancelled",
};
return { return {
success: false, success: false,
outputPath, outputPath,
durationMs: result.durationMs, durationMs: result.durationMs,
error: "Audio extract cancelled", error: failure.detail,
failure,
}; };
} }
if (!result.success) { if (!result.success) {
const failure = ffmpegFailure("extract", result);
return { return {
success: false, success: false,
outputPath, outputPath,
durationMs: result.durationMs, durationMs: result.durationMs,
error: error: failure.detail,
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr, failure,
}; };
} }
return { success: true, outputPath, durationMs: result.durationMs }; return { success: true, outputPath, durationMs: result.durationMs };
@@ -317,22 +435,28 @@ async function prepareAudioTrack(
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout }); const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (signal?.aborted) { if (signal?.aborted) {
const failure: AudioProcessingFailure = {
stage: "cancelled",
reason: "cancelled",
owner: "user",
retryable: false,
detail: "Audio prepare cancelled",
};
return { return {
success: false, success: false,
outputPath, outputPath,
durationMs: result.durationMs, durationMs: result.durationMs,
error: "Audio prepare cancelled", error: failure.detail,
failure,
}; };
} }
const failure = !result.success ? ffmpegFailure("prepare", result) : undefined;
return { return {
success: result.success, success: result.success,
outputPath, outputPath,
durationMs: result.durationMs, durationMs: result.durationMs,
error: !result.success error: failure?.detail,
? result.exitCode !== null failure,
? `FFmpeg exited with code ${result.exitCode}: ${result.stderr.slice(-200)}`
: result.stderr
: undefined,
}; };
} }
@@ -362,22 +486,28 @@ async function generateSilence(
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout }); const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
if (signal?.aborted) { if (signal?.aborted) {
const failure: AudioProcessingFailure = {
stage: "cancelled",
reason: "cancelled",
owner: "user",
retryable: false,
detail: "Silence generation cancelled",
};
return { return {
success: false, success: false,
outputPath, outputPath,
durationMs: result.durationMs, durationMs: result.durationMs,
error: "Silence generation cancelled", error: failure.detail,
failure,
}; };
} }
const failure = !result.success ? ffmpegFailure("silence", result) : undefined;
return { return {
success: result.success, success: result.success,
outputPath, outputPath,
durationMs: result.durationMs, durationMs: result.durationMs,
error: !result.success error: failure?.detail,
? result.exitCode !== null failure,
? `FFmpeg exited with code ${result.exitCode}`
: result.stderr
: undefined,
}; };
} }
@@ -399,6 +529,7 @@ async function mixAudioTracks(
durationMs: result.durationMs, durationMs: result.durationMs,
tracksProcessed: 0, tracksProcessed: 0,
error: result.error, error: result.error,
failures: result.failure ? [result.failure] : undefined,
}; };
} }
@@ -412,7 +543,7 @@ async function mixAudioTracks(
const trimDuration = track.end - track.start; const trimDuration = track.end - track.start;
const volumeFilter = buildVolumeExpression(track, ignoreAutomation); const volumeFilter = buildVolumeExpression(track, ignoreAutomation);
filterParts.push( 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, durationMs: result.durationMs,
tracksProcessed: 0, tracksProcessed: 0,
error: "Audio mix cancelled", error: "Audio mix cancelled",
failures: [
{
stage: "cancelled",
reason: "cancelled",
owner: "user",
retryable: false,
detail: "Audio mix cancelled",
},
],
}; };
} }
if (!result.success) { if (!result.success) {
const failure = ffmpegFailure("mix", result);
return { return {
success: false, success: false,
outputPath, outputPath,
durationMs: result.durationMs, durationMs: result.durationMs,
tracksProcessed: 0, tracksProcessed: 0,
error: error: failure.detail,
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr, failures: [failure],
}; };
} }
return { return {
@@ -534,14 +675,21 @@ export async function processCompositionAudio(
): Promise<MixResult> { ): Promise<MixResult> {
const startMs = Date.now(); const startMs = Date.now();
const tracks: AudioTrack[] = []; const tracks: AudioTrack[] = [];
const errors: string[] = []; const failures: AudioProcessingFailure[] = [];
if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true }); if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
await Promise.all( await Promise.all(
elements.map(async (element) => { elements.map(async (element) => {
if (signal?.aborted) { 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; return;
} }
try { try {
@@ -556,21 +704,36 @@ export async function processCompositionAudio(
try { try {
srcPath = await downloadToTemp(srcPath, workDir); srcPath = await downloadToTemp(srcPath, workDir);
} catch (err: unknown) { } catch (err: unknown) {
errors.push( failures.push(
`Download failed: ${element.id}${err instanceof Error ? err.message : String(err)}`, downloadFailure(err instanceof Error ? err.message : String(err), element.id),
); );
return; return;
} }
} }
if (!existsSync(srcPath)) { 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; return;
} }
// Fallback: if no duration was specified, probe the actual file // Fallback: if no duration was specified, probe the actual file
if (element.end - element.start <= 0) { 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; const effectiveDuration = metadata.durationSeconds - element.mediaStart;
element.end = element.end =
element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds); element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
@@ -590,7 +753,18 @@ export async function processCompositionAudio(
config, config,
); );
if (!extractResult.success) { 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; return;
} }
audioSrcPath = extractedPath; audioSrcPath = extractedPath;
@@ -605,7 +779,18 @@ export async function processCompositionAudio(
config, config,
); );
if (!prepResult.success) { 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; return;
} }
audioSrcPath = trimmedPath; audioSrcPath = trimmedPath;
@@ -636,7 +821,18 @@ export async function processCompositionAudio(
volumeKeyframes: bakedEnvelope ? undefined : element.volumeKeyframes, volumeKeyframes: bakedEnvelope ? undefined : element.volumeKeyframes,
}); });
} catch (err: unknown) { } 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 producer only surfaces audio failures when `success` is false; mixing
// the remaining tracks made the omitted cue indistinguishable from a valid // the remaining tracks made the omitted cue indistinguishable from a valid
// render unless someone manually audited that exact audio window. // render unless someone manually audited that exact audio window.
if (errors.length > 0) { if (failures.length > 0) {
try { try {
rmSync(workDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true });
} catch { } catch {
@@ -656,7 +852,10 @@ export async function processCompositionAudio(
outputPath, outputPath,
durationMs: Date.now() - startMs, durationMs: Date.now() - startMs,
tracksProcessed: tracks.length, 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 { return {
...mixResult, ...mixResult,
durationMs: Date.now() - startMs, 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[]; 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 { export interface MixResult {
success: boolean; success: boolean;
outputPath: string; outputPath: string;
durationMs: number; durationMs: number;
tracksProcessed: number; tracksProcessed: number;
error?: string; 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, CaptureWarning,
SubTimelineWaitOutcome, SubTimelineWaitOutcome,
} from "../types.js"; } from "../types.js";
import { cloneCaptureWarnings } from "./captureWarning.js";
export { isMemoryExhaustionError, isTransientBrowserError } from "./captureFailure.js"; export { isMemoryExhaustionError, isTransientBrowserError } from "./captureFailure.js";
export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary }; export type { CaptureOptions, CaptureResult, CaptureBufferResult, CapturePerfSummary };
@@ -3773,15 +3774,7 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95), p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95),
p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99), p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99),
subTimelineWaitOutcome: session.subTimelineWaitOutcome, subTimelineWaitOutcome: session.subTimelineWaitOutcome,
warnings: session.warnings.map((warning) => ({ warnings: cloneCaptureWarnings(session.warnings),
...warning,
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
})),
staticDedupReused: session.staticDedupCount ?? 0, staticDedupReused: session.staticDedupCount ?? 0,
staticDedupEnabled: session.staticDedupEnabled ?? false, staticDedupEnabled: session.staticDedupEnabled ?? false,
// armed ⟺ a non-empty static set survived verification; predicted === its size. // armed ⟺ a non-empty static set survived verification; predicted === its size.
+4
View File
@@ -29,6 +29,10 @@ export interface CaptureWarning {
mediaType?: "image" | "video" | "audio"; mediaType?: "image" | "video" | "audio";
sources?: string[]; sources?: string[];
timeoutMs?: number; 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; const trimDuration = track.duration > 0 ? track.duration : totalDuration;
filterParts.push( 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`. * Unit tests for `services/distributed/plan.ts`.
* *
@@ -76,8 +77,63 @@ describe("distributed warning policy", () => {
it("rejects distributed audio degradation in best-effort mode", () => { it("rejects distributed audio degradation in best-effort mode", () => {
const job = createJob("best-effort"); 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.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, getEncoderPreset,
normalizeVp9CpuUsed, normalizeVp9CpuUsed,
resolveConfig, resolveConfig,
type AudioProcessingFailure,
} from "@hyperframes/engine"; } from "@hyperframes/engine";
import { defaultLogger, type ProducerLogger } from "../../logger.js"; import { defaultLogger, type ProducerLogger } from "../../logger.js";
import { import {
@@ -269,15 +270,30 @@ export interface PlanResult {
export function applyDistributedAudioWarningPolicy( export function applyDistributedAudioWarningPolicy(
job: RenderJob, job: RenderJob,
audioError: string, audioError: string,
audioFailures: readonly AudioProcessingFailure[] = [],
log: ProducerLogger = defaultLogger, log: ProducerLogger = defaultLogger,
): void { ): 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( applyRenderWarningPolicy(
job, job,
[ [
{ {
code: "audio_processing_failed", code: "audio_processing_failed",
message: `Audio mix failed; output would be video-only: ${audioError}`, 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, log,
@@ -943,7 +959,7 @@ export async function plan(
assertNotAborted, assertNotAborted,
}); });
if (audioResult.audioError) { 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 // 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- // `///C:/…`, which Windows path parsing then rejects). Field-
// signal reports ts=1784169914 / 1784177061 / 1784177375 (all // signal reports ts=1784169914 / 1784177061 / 1784177375 (all
// win32/x64 CLI 0.7.59; the last isolated the module's arg shape // 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` — // 2. Bare `/tmp/…` when the concat script was fed via `pipe:0` —
// FFmpeg's URL joiner resolves absolute POSIX paths against the // FFmpeg's URL joiner resolves absolute POSIX paths against the
// base `pipe:` URL, producing `pipe:/tmp/…` which the demuxer // 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 () => { it("contains sink rejection and still delivers the terminal event", async () => {
const delivered: number[] = []; const delivered: number[] = [];
const warn = vi.fn(); const warn = vi.fn();
@@ -147,6 +179,71 @@ describe("updateJobStatus", () => {
).toThrow(RenderQualityError); ).toThrow(RenderQualityError);
expect(job.config.strictness).toBe("best-effort"); expect(job.config.strictness).toBe("best-effort");
expect(job.warnings).toHaveLength(1); 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", () => { 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 { ProducerLogger } from "../../logger.js";
import type { ProgressCallback, RenderJob } from "../renderOrchestrator.js"; import type { ProgressCallback, RenderJob } from "../renderOrchestrator.js";
import { updateJobStatus } from "./shared.js"; import { updateJobStatus } from "./shared.js";
@@ -5,15 +6,7 @@ import { updateJobStatus } from "./shared.js";
function snapshotJob(job: RenderJob): RenderJob { function snapshotJob(job: RenderJob): RenderJob {
return { return {
...job, ...job,
warnings: job.warnings.map((warning) => ({ warnings: cloneCaptureWarnings(job.warnings),
...warning,
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
})),
}; };
} }
@@ -53,12 +53,25 @@ describe("runAudioStage", () => {
durationMs: 1, durationMs: 1,
tracksProcessed: 0, tracksProcessed: 0,
error: "Source not found: a1 (narration.wav)", 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()); const result = await runAudioStage(makeInput());
expect(result.hasAudio).toBe(false); expect(result.hasAudio).toBe(false);
expect(result.audioError).toBe("Source not found: a1 (narration.wav)"); 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 () => { 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(processCompositionAudioMock).not.toHaveBeenCalled();
expect(result.hasAudio).toBe(false); expect(result.hasAudio).toBe(false);
expect(result.audioError).toBeUndefined(); expect(result.audioError).toBeUndefined();
expect(result.audioFailures).toBeUndefined();
}); });
}); });
@@ -16,7 +16,7 @@
*/ */
import { join } from "node:path"; import { join } from "node:path";
import { processCompositionAudio } from "@hyperframes/engine"; import { processCompositionAudio, type AudioProcessingFailure } from "@hyperframes/engine";
import type { CompositionMetadata } from "../shared.js"; import type { CompositionMetadata } from "../shared.js";
export interface AudioStageInput { export interface AudioStageInput {
@@ -45,6 +45,8 @@ export interface AudioStageResult {
* both when there was no audio to mix and when the mix succeeded. * both when there was no audio to mix and when the mix succeeded.
*/ */
audioError?: string; audioError?: string;
/** Bounded typed causes for policy, telemetry, and caller classification. */
audioFailures?: AudioProcessingFailure[];
} }
export async function runAudioStage(input: AudioStageInput): Promise<AudioStageResult> { 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"); const audioOutputPath = join(workDir, "audio.aac");
let hasAudio = false; let hasAudio = false;
let audioError: string | undefined; let audioError: string | undefined;
let audioFailures: AudioProcessingFailure[] | undefined;
if (audios.length > 0) { if (audios.length > 0) {
const audioResult = await processCompositionAudio( const audioResult = await processCompositionAudio(
@@ -70,6 +73,7 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
assertNotAborted(); assertNotAborted();
hasAudio = audioResult.success; hasAudio = audioResult.success;
audioFailures = audioResult.failures;
// processCompositionAudio's error (per-element failures or the mix's own // processCompositionAudio's error (per-element failures or the mix's own
// error) used to be discarded here — the caller only saw hasAudio flip to // 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 // 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; const audioProcessMs = Date.now() - stage3Start;
return { audioOutputPath, hasAudio, audioProcessMs, audioError }; return {
audioOutputPath,
hasAudio,
audioProcessMs,
audioError,
audioFailures,
};
} }
@@ -44,6 +44,7 @@ import {
type HdrTransfer, type HdrTransfer,
type StreamingEncoder, type StreamingEncoder,
closeCaptureSession, closeCaptureSession,
cloneCaptureWarnings,
createCaptureSession, createCaptureSession,
getEncoderPreset, getEncoderPreset,
initTransparentBackground, initTransparentBackground,
@@ -128,18 +129,6 @@ export interface CaptureHdrStageResult {
warnings: CaptureWarning[]; 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( export async function runCaptureHdrStage(
input: CaptureHdrStageInput, input: CaptureHdrStageInput,
): Promise<CaptureHdrStageResult> { ): Promise<CaptureHdrStageResult> {
@@ -82,6 +82,7 @@ import {
applyConcreteGpuScreenshotClamp, applyConcreteGpuScreenshotClamp,
scaleProtocolTimeoutForComposition, scaleProtocolTimeoutForComposition,
classifyCaptureFailure, classifyCaptureFailure,
cloneCaptureWarning,
isMemoryExhaustionError, isMemoryExhaustionError,
isDrawElementVerificationError, isDrawElementVerificationError,
getDrawElementVerificationDetails, getDrawElementVerificationDetails,
@@ -613,22 +614,28 @@ export function applyRenderWarningPolicy(
if (existing.has(key)) continue; if (existing.has(key)) continue;
existing.add(key); existing.add(key);
job.warnings.push({ job.warnings.push({
...warning, ...cloneCaptureWarning(warning),
stage: "capture-readiness", stage: "capture-readiness",
details: warning.details
? {
...warning.details,
sources: warning.details.sources ? [...warning.details.sources] : undefined,
}
: undefined,
}); });
} }
if (job.warnings.length === 0) return; if (job.warnings.length === 0) return;
const strictness = job.config.strictness ?? "best-effort"; 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", { log.warn("Render completed capture with correctness warnings", {
strictness, strictness,
warningCodes: job.warnings.map((warning) => warning.code), 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( const hasAudioProcessingFailure = job.warnings.some(
(warning) => warning.code === "audio_processing_failed", (warning) => warning.code === "audio_processing_failed",
@@ -2184,13 +2191,30 @@ async function executeRenderPipeline(input: {
const { audioOutputPath, hasAudio } = audioResult; const { audioOutputPath, hasAudio } = audioResult;
perfStages.audioProcessMs = audioResult.audioProcessMs; perfStages.audioProcessMs = audioResult.audioProcessMs;
if (audioResult.audioError) { 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( applyRenderWarningPolicy(
job, job,
[ [
{ {
code: "audio_processing_failed", code: "audio_processing_failed",
message: `Audio mix failed; output would be video-only: ${audioResult.audioError}`, 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, log,