mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(audio): preserve subprocess causes through CLI diagnostics (#5620)
* fix(audio): preserve subprocess evidence through CLI diagnostics * test(producer): retain real engine exports in assembly mock * fix(audio): retain triggering interruption within diagnostic cap * fix(producer): sanitize audio evidence before encoder error construction * test(cli): isolate diagnostic render temporary project
This commit is contained in:
@@ -75,6 +75,20 @@ A render stops when the process that started it exits. To keep one running after
|
||||
its launcher exits, set `HYPERFRAMES_RENDER_DETACHED=1` and start it in its own
|
||||
process group, e.g. `bash -c 'set -m; HYPERFRAMES_RENDER_DETACHED=1 npx hyperframes render -o out.mp4 < /dev/null > render.log 2>&1 &'`.
|
||||
|
||||
Piped, non-quiet renders emit a single `@hf-diagnostic ` JSON line when an audio
|
||||
failure reaches the CLI error handler. Version 1 has `operation: "render"` and
|
||||
at most 16 `audioFailures`: allowlisted stage, reason, owner, retryable, and
|
||||
optional subprocess exit code, signal, termination reason and OS code. It omits
|
||||
messages, stderr, source paths/URLs, element IDs and command arguments. Consumers
|
||||
must ignore unknown versions and fields. This is local CLI output; uploading it
|
||||
requires the caller's existing consent and privacy checks. Quiet mode (including
|
||||
batch JSON mode) emits no diagnostic line.
|
||||
|
||||
This covers audio preparation and mix failures, including the typed external
|
||||
interruption path. Probe failures retain their existing stage/reason but do not
|
||||
gain subprocess evidence here; browser/FX failures remain classified as internal.
|
||||
Desktop ingestion and live report delivery are separate changes.
|
||||
|
||||
### `publish`
|
||||
|
||||
Upload a project directory and get a hosted URL that keeps working after the CLI exits.
|
||||
|
||||
@@ -617,6 +617,69 @@ describe("renderLocal browser GPU config", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ quiet: false, interrupted: false, emits: true },
|
||||
{ quiet: false, interrupted: true, emits: true },
|
||||
{ quiet: true, interrupted: false, emits: false },
|
||||
])("writes the failure diagnostic only for piped non-JSON output: %j", async (mode) => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-diagnostic-"));
|
||||
const failures = [
|
||||
{
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
subprocess: { exitCode: 7, terminationReason: "exit" },
|
||||
detail: "private-detail",
|
||||
elementId: "private-id",
|
||||
},
|
||||
];
|
||||
producerState.executeImpl = async (job) => {
|
||||
if (mode.interrupted) throw Object.assign(new Error("failed"), { audioFailures: failures });
|
||||
job.warnings = [
|
||||
{
|
||||
code: "audio_processing_failed",
|
||||
message: "private-warning",
|
||||
details: { audioFailures: failures },
|
||||
},
|
||||
];
|
||||
throw new Error("failed");
|
||||
};
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
||||
let output = "";
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
renderLocal(projectDir, join(projectDir, "out.mp4"), {
|
||||
fps: { num: 30, den: 1 },
|
||||
quality: "standard",
|
||||
format: "mp4",
|
||||
gpu: false,
|
||||
browserGpuMode: "software",
|
||||
hdrMode: "auto",
|
||||
quiet: mode.quiet,
|
||||
throwOnError: true,
|
||||
}),
|
||||
).rejects.toThrow("failed");
|
||||
const lines = output.split("\n").filter((line) => line.startsWith("@hf-diagnostic "));
|
||||
expect(lines).toHaveLength(mode.emits ? 1 : 0);
|
||||
if (mode.emits) {
|
||||
expect(
|
||||
JSON.parse((lines[0] ?? "").slice("@hf-diagnostic ".length)).audioFailures[0].subprocess
|
||||
.exitCode,
|
||||
).toBe(7);
|
||||
expect(lines[0] ?? "").not.toMatch(/private-/);
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true });
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("logs only warnings and errors from the producer under --quiet", async () => {
|
||||
const options = {
|
||||
fps: { num: 30, den: 1 },
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
errorBox,
|
||||
} from "../ui/format.js";
|
||||
import { warnIfWebmAlphaDropped } from "../utils/webmAlphaCheck.js";
|
||||
import { renderFailureDiagnostic } from "../ui/renderDiagnostic.js";
|
||||
import { renderProgress, renderMachineProgress } from "../ui/progress.js";
|
||||
import {
|
||||
trackRenderComplete,
|
||||
@@ -1705,6 +1706,7 @@ function handleRenderError(
|
||||
job?: RenderJob,
|
||||
): never {
|
||||
const message = normalizeErrorMessage(error);
|
||||
renderFailureDiagnostic(job, error, options.quiet);
|
||||
trackRenderError({
|
||||
fps: fpsToNumber(options.fps),
|
||||
quality: options.quality,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { processCompositionAudio, type AudioFailureDiagnostic } from "@hyperframes/engine";
|
||||
import { applyDistributedAudioWarningPolicy } from "../../../producer/src/services/distributed/plan.js";
|
||||
import { buildSyntheticRenderJob } from "../../../producer/src/services/distributed/shared.js";
|
||||
import { createRenderJob, executeRenderJob, type RenderJob } from "@hyperframes/producer";
|
||||
import { runAudioStage } from "../../../producer/src/services/render/stages/audioStage.js";
|
||||
import { renderFailureDiagnostic, renderMachineDiagnostic } from "./renderDiagnostic.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const originalIsTTY = process.stdout.isTTY;
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true });
|
||||
vi.unstubAllEnvs();
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function capture(values: readonly unknown[] | (() => void), terminal = false): string {
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: terminal, configurable: true });
|
||||
let output = "";
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
});
|
||||
if (typeof values === "function") values();
|
||||
else renderMachineDiagnostic(values);
|
||||
return output;
|
||||
}
|
||||
|
||||
function captureJob(job: Pick<RenderJob, "warnings">): string {
|
||||
return capture(job.warnings.flatMap((warning) => warning.details?.audioFailures ?? []));
|
||||
}
|
||||
|
||||
const PRIVATE =
|
||||
"/private/customer/project?token=secret-token element-PRIVATE https://private.test/prompt";
|
||||
const failure = {
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
detail: PRIVATE,
|
||||
elementId: PRIVATE,
|
||||
subprocess: { exitCode: 7, terminationReason: "exit", stderr: PRIVATE, args: [PRIVATE] },
|
||||
};
|
||||
|
||||
describe("render diagnostic wire", () => {
|
||||
it("re-projects every field and bounds the record count at the final writer", () => {
|
||||
const line = capture(Array.from({ length: 100 }, () => failure));
|
||||
const parsed = JSON.parse(line.slice("@hf-diagnostic ".length));
|
||||
expect(parsed.version).toBe(1);
|
||||
expect(parsed.operation).toBe("render");
|
||||
expect(parsed.audioFailures).toHaveLength(16);
|
||||
expect(parsed.audioFailures[0]).toEqual({
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
subprocess: { exitCode: 7, terminationReason: "exit" },
|
||||
});
|
||||
expect(line).not.toContain(PRIVATE);
|
||||
expect(line.trim().split("\n")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["stage", "reason", "owner", "retryable"])(
|
||||
"rejects private text substituted for %s",
|
||||
(key) => {
|
||||
expect(capture([{ ...failure, [key]: PRIVATE }])).toBe("");
|
||||
},
|
||||
);
|
||||
|
||||
it("drops non-allowlisted subprocess strings and unbounded exit codes", () => {
|
||||
const line = capture([
|
||||
{
|
||||
...failure,
|
||||
subprocess: {
|
||||
exitCode: Infinity,
|
||||
signal: PRIVATE,
|
||||
terminationReason: PRIVATE,
|
||||
code: PRIVATE,
|
||||
message: PRIVATE,
|
||||
error: { code: PRIVATE, message: PRIVATE },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(JSON.parse(line.slice("@hf-diagnostic ".length)).audioFailures[0].subprocess).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(line).not.toContain(PRIVATE);
|
||||
});
|
||||
|
||||
it("stays silent for a terminal or a container attached to a terminal", () => {
|
||||
expect(capture([failure], true)).toBe("");
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("HYPERFRAMES_STDOUT_IS_TTY", "1");
|
||||
expect(capture([failure])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
const cases: Array<{ name: string; script?: string; expected: AudioFailureDiagnostic }> = [
|
||||
{
|
||||
name: "missing executable",
|
||||
expected: {
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_unavailable",
|
||||
owner: "system",
|
||||
retryable: true,
|
||||
subprocess: { terminationReason: "spawn_error", code: "ENOENT" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ordinary exit",
|
||||
script: 'process.stderr.write("private-customer-stderr"); process.exit(7);',
|
||||
expected: {
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
subprocess: { exitCode: 7, terminationReason: "exit" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deadline",
|
||||
script: "setInterval(() => {}, 1000);",
|
||||
expected: {
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_timeout",
|
||||
owner: "system",
|
||||
retryable: true,
|
||||
subprocess: { signal: "SIGTERM", terminationReason: "deadline" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group submix",
|
||||
script:
|
||||
'const fs = require("node:fs"); if (process.argv.includes("-filter_complex_script")) process.exit(9); fs.writeFileSync(process.argv.at(-1), "audio");',
|
||||
expected: {
|
||||
stage: "mix",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
subprocess: { exitCode: 9, terminationReason: "exit" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// The executable stand-ins use a POSIX shebang; field validation above runs on every OS.
|
||||
for (const scenario of cases) {
|
||||
it.skipIf(process.platform === "win32")(
|
||||
`carries a real ${scenario.name} through the mixer, producer policy and final CLI line`,
|
||||
async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-diagnostic-private-"));
|
||||
roots.push(root);
|
||||
const executable = join(root, "ffmpeg-private");
|
||||
if (scenario.script) {
|
||||
writeFileSync(executable, `#!${process.execPath}\n${scenario.script}\n`);
|
||||
chmodSync(executable, 0o755);
|
||||
}
|
||||
vi.stubEnv("HYPERFRAMES_FFMPEG_PATH", executable);
|
||||
writeFileSync(join(root, "private.wav"), "RIFF0000WAVEfmt ");
|
||||
const result = await processCompositionAudio(
|
||||
[
|
||||
{
|
||||
id: "private-element",
|
||||
src: "private.wav",
|
||||
type: "audio",
|
||||
start: 0,
|
||||
end: 1,
|
||||
mediaStart: 0,
|
||||
layer: 0,
|
||||
volume: 1,
|
||||
...(scenario.name === "group submix"
|
||||
? { groupId: "private-group", groupVolume: 1 }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
root,
|
||||
join(root, "work"),
|
||||
join(root, "out.m4a"),
|
||||
1,
|
||||
undefined,
|
||||
{ ffmpegProcessTimeout: scenario.name === "deadline" ? 200 : 5_000 },
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
const job = buildSyntheticRenderJob({
|
||||
fps: { num: 30, den: 1 },
|
||||
format: "mp4",
|
||||
quality: "high",
|
||||
hdrMode: "force-sdr",
|
||||
entryFile: "index.html",
|
||||
});
|
||||
expect(() =>
|
||||
applyDistributedAudioWarningPolicy(job, result.error ?? "failed", result.failures),
|
||||
).toThrow();
|
||||
const line = captureJob(job);
|
||||
expect(JSON.parse(line.slice("@hf-diagnostic ".length)).audioFailures).toEqual([
|
||||
scenario.expected,
|
||||
]);
|
||||
for (const secret of [
|
||||
root,
|
||||
"private.wav",
|
||||
"private-element",
|
||||
"private-group",
|
||||
"private-customer-stderr",
|
||||
])
|
||||
expect(line).not.toContain(secret);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("carries a local render audio failure through the real orchestration warning policy", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-local-audio-diagnostic-"));
|
||||
roots.push(root);
|
||||
vi.stubEnv("HYPERFRAMES_FFMPEG_PATH", process.execPath);
|
||||
writeFileSync(join(root, "private.wav"), "RIFF0000WAVEfmt ");
|
||||
writeFileSync(
|
||||
join(root, "index.html"),
|
||||
`<html><body><div data-composition-id="root" data-width="320" data-height="240" data-duration="1"><audio id="private-cue" src="private.wav" data-start="0" data-duration="1"></audio></div></body></html>`,
|
||||
);
|
||||
const job = createRenderJob({ fps: 30, quality: "standard", hdrMode: "force-sdr" });
|
||||
await expect(executeRenderJob(job, root, join(root, "out.mp4"))).rejects.toThrow();
|
||||
const line = captureJob(job);
|
||||
expect(JSON.parse(line.slice("@hf-diagnostic ".length)).audioFailures).toEqual([
|
||||
{
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
subprocess: { exitCode: 9, terminationReason: "exit" },
|
||||
},
|
||||
]);
|
||||
for (const secret of [root, "private.wav", "private-cue"]) expect(line).not.toContain(secret);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps the triggering seventeenth interruption in the final bounded record",
|
||||
async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-audio-mixed-failure-"));
|
||||
roots.push(root);
|
||||
const executable = join(root, "ffmpeg");
|
||||
writeFileSync(executable, `#!${process.execPath}\nprocess.kill(process.pid, "SIGTERM");\n`);
|
||||
chmodSync(executable, 0o755);
|
||||
vi.stubEnv("HYPERFRAMES_FFMPEG_PATH", executable);
|
||||
writeFileSync(join(root, "private.wav"), "RIFF0000WAVEfmt ");
|
||||
const track = {
|
||||
id: "private-cue",
|
||||
src: "private.wav",
|
||||
type: "audio" as const,
|
||||
start: 0,
|
||||
end: 1,
|
||||
mediaStart: 0,
|
||||
volume: 1,
|
||||
layer: 0,
|
||||
};
|
||||
let interrupted: unknown;
|
||||
try {
|
||||
await runAudioStage({
|
||||
projectDir: root,
|
||||
compiledDir: root,
|
||||
workDir: join(root, "work"),
|
||||
duration: 1,
|
||||
ffmpegProcessTimeout: 5_000,
|
||||
audioGain: 1,
|
||||
abortSignal: undefined,
|
||||
assertNotAborted: () => {},
|
||||
audios: [
|
||||
...Array.from({ length: 16 }, (_, i) => ({
|
||||
...track,
|
||||
id: `private-missing-${i}`,
|
||||
src: `private-missing-${i}.wav`,
|
||||
})),
|
||||
track,
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
interrupted = error;
|
||||
}
|
||||
expect(interrupted).toBeInstanceOf(Error);
|
||||
const line = capture(() => renderFailureDiagnostic(undefined, interrupted, false));
|
||||
const records = JSON.parse(line.slice("@hf-diagnostic ".length)).audioFailures;
|
||||
expect(records).toHaveLength(16);
|
||||
expect(records[0]).toEqual({
|
||||
stage: "prepare",
|
||||
reason: "external_interruption",
|
||||
owner: "system",
|
||||
retryable: true,
|
||||
subprocess: { signal: "SIGTERM", terminationReason: "exit" },
|
||||
});
|
||||
expect(
|
||||
records.slice(1).every((entry: { reason: string }) => entry.reason === "source_not_found"),
|
||||
).toBe(true);
|
||||
expect(line).not.toMatch(/private-|secret/);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { RenderJob } from "@hyperframes/producer";
|
||||
import { audioFailureDiagnostics } from "@hyperframes/engine";
|
||||
|
||||
/** Versioned, content-free failure line for Desktop's piped CLI, separate from human error output. */
|
||||
export function renderMachineDiagnostic(values: readonly unknown[]): void {
|
||||
if (process.stdout.isTTY || process.env.HYPERFRAMES_STDOUT_IS_TTY === "1") return;
|
||||
const failures = audioFailureDiagnostics(values);
|
||||
if (failures.length === 0) return;
|
||||
process.stdout.write(
|
||||
`@hf-diagnostic ${JSON.stringify({ version: 1, operation: "render", audioFailures: failures })}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
export function renderFailureDiagnostic(
|
||||
job: RenderJob | undefined,
|
||||
error: unknown,
|
||||
quiet: boolean,
|
||||
): void {
|
||||
if (quiet) return;
|
||||
const interruptedFailures =
|
||||
error !== null &&
|
||||
typeof error === "object" &&
|
||||
"audioFailures" in error &&
|
||||
Array.isArray(error.audioFailures)
|
||||
? error.audioFailures
|
||||
: [];
|
||||
renderMachineDiagnostic([
|
||||
...interruptedFailures,
|
||||
...(job?.warnings.flatMap((warning) => warning.details?.audioFailures ?? []) ?? []),
|
||||
]);
|
||||
}
|
||||
@@ -263,12 +263,15 @@ export {
|
||||
parseMasterFxChain,
|
||||
processCompositionAudio,
|
||||
} from "./services/audioMixer.js";
|
||||
export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
|
||||
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
|
||||
export type {
|
||||
AudioElement,
|
||||
AudioFailureReason,
|
||||
AudioFailureStage,
|
||||
AudioProcessingFailure,
|
||||
AudioFailureDiagnostic,
|
||||
AudioSubprocessEvidence,
|
||||
AudioTrack,
|
||||
AudioVolumeKeyframe,
|
||||
MixResult,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { AudioFailureDiagnostic, AudioSubprocessEvidence } from "./audioMixer.types.js";
|
||||
|
||||
const STAGES = new Set([
|
||||
"source",
|
||||
"download",
|
||||
"probe",
|
||||
"extract",
|
||||
"prepare",
|
||||
"mix",
|
||||
"silence",
|
||||
"cancelled",
|
||||
"internal",
|
||||
]);
|
||||
const REASONS = new Set([
|
||||
"source_not_found",
|
||||
"download_failed",
|
||||
"probe_failed",
|
||||
"invalid_media",
|
||||
"ffmpeg_unsupported",
|
||||
"ffmpeg_timeout",
|
||||
"ffmpeg_unavailable",
|
||||
"external_interruption",
|
||||
"ffmpeg_failed",
|
||||
"cancelled",
|
||||
"internal",
|
||||
]);
|
||||
const TERMINATIONS = new Set(["exit", "abort", "deadline", "inactivity", "spawn_error"]);
|
||||
const SIGNALS = new Set([
|
||||
"SIGABRT",
|
||||
"SIGBUS",
|
||||
"SIGFPE",
|
||||
"SIGHUP",
|
||||
"SIGILL",
|
||||
"SIGINT",
|
||||
"SIGKILL",
|
||||
"SIGPIPE",
|
||||
"SIGQUIT",
|
||||
"SIGSEGV",
|
||||
"SIGTERM",
|
||||
"SIGTRAP",
|
||||
"SIGXCPU",
|
||||
"SIGXFSZ",
|
||||
]);
|
||||
const CODES = new Set([
|
||||
"EACCES",
|
||||
"EAGAIN",
|
||||
"EBUSY",
|
||||
"EIO",
|
||||
"EISDIR",
|
||||
"EMFILE",
|
||||
"ENAMETOOLONG",
|
||||
"ENFILE",
|
||||
"ENOENT",
|
||||
"ENOEXEC",
|
||||
"ENOMEM",
|
||||
"ENOSPC",
|
||||
"ENOTDIR",
|
||||
"EPERM",
|
||||
"ETIMEDOUT",
|
||||
]);
|
||||
|
||||
function isStage(value: unknown): value is AudioFailureDiagnostic["stage"] {
|
||||
return typeof value === "string" && STAGES.has(value);
|
||||
}
|
||||
|
||||
function isReason(value: unknown): value is AudioFailureDiagnostic["reason"] {
|
||||
return typeof value === "string" && REASONS.has(value);
|
||||
}
|
||||
|
||||
function isTermination(
|
||||
value: unknown,
|
||||
): value is NonNullable<AudioSubprocessEvidence["terminationReason"]> {
|
||||
return typeof value === "string" && TERMINATIONS.has(value);
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
export function audioSubprocessEvidence(value: unknown): AudioSubprocessEvidence {
|
||||
if (!record(value)) return {};
|
||||
const evidence: AudioSubprocessEvidence = {};
|
||||
if (
|
||||
typeof value.exitCode === "number" &&
|
||||
Number.isInteger(value.exitCode) &&
|
||||
value.exitCode >= -2147483648 &&
|
||||
value.exitCode <= 4294967295
|
||||
)
|
||||
evidence.exitCode = value.exitCode;
|
||||
if (typeof value.signal === "string" && SIGNALS.has(value.signal)) evidence.signal = value.signal;
|
||||
if (isTermination(value.terminationReason)) evidence.terminationReason = value.terminationReason;
|
||||
const error = record(value.error) ? value.error : value;
|
||||
if (typeof error.code === "string" && CODES.has(error.code)) evidence.code = error.code;
|
||||
return evidence;
|
||||
}
|
||||
|
||||
/** Re-project at the wire boundary: neither typed callers nor warning details are a privacy boundary. */
|
||||
export function audioFailureDiagnostics(values: readonly unknown[]): AudioFailureDiagnostic[] {
|
||||
const result: AudioFailureDiagnostic[] = [];
|
||||
for (const value of values.slice(0, 16)) {
|
||||
if (
|
||||
!record(value) ||
|
||||
!isStage(value.stage) ||
|
||||
!isReason(value.reason) ||
|
||||
(value.owner !== "user" && value.owner !== "system") ||
|
||||
typeof value.retryable !== "boolean"
|
||||
)
|
||||
continue;
|
||||
result.push({
|
||||
stage: value.stage,
|
||||
reason: value.reason,
|
||||
owner: value.owner,
|
||||
retryable: value.retryable,
|
||||
...(record(value.subprocess)
|
||||
? { subprocess: audioSubprocessEvidence(value.subprocess) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../utils/urlDownloader.js";
|
||||
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
|
||||
import { audioSubprocessEvidence } from "./audioFailureDiagnostic.js";
|
||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
|
||||
import { resolveReferencedStart, type RefResolverEl } from "./referenceResolver.js";
|
||||
@@ -466,6 +467,7 @@ function ffmpegFailure(
|
||||
owner,
|
||||
retryable,
|
||||
elementId,
|
||||
subprocess: audioSubprocessEvidence(result),
|
||||
detail: boundedDetail(
|
||||
result.error?.message
|
||||
? `${formatFfmpegError(result.exitCode, stderr)}: ${result.error.message}`
|
||||
@@ -953,7 +955,10 @@ async function mixGroupMembers(
|
||||
totalDuration: number,
|
||||
signal?: AbortSignal,
|
||||
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
||||
): Promise<{ success: boolean; error?: string; degradedAutomation?: boolean }> {
|
||||
): Promise<
|
||||
| { success: true; degradedAutomation?: boolean }
|
||||
| { success: false; error: string; failure: AudioProcessingFailure }
|
||||
> {
|
||||
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
||||
const outputDir = dirname(outputPath);
|
||||
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
||||
@@ -1046,9 +1051,10 @@ async function mixGroupMembers(
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) return { success: false, error: "Group sub-mix cancelled" };
|
||||
if (!result.success)
|
||||
return { success: false, error: formatFfmpegError(result.exitCode, result.stderr) };
|
||||
if (signal?.aborted || !result.success) {
|
||||
const failure = ffmpegFailure("mix", result);
|
||||
return { success: false, error: failure.detail, failure };
|
||||
}
|
||||
return { success: true, degradedAutomation };
|
||||
}
|
||||
|
||||
@@ -1508,10 +1514,7 @@ export async function processCompositionAudio(
|
||||
);
|
||||
if (!subMix.success) {
|
||||
failures.push({
|
||||
stage: "mix",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
...subMix.failure,
|
||||
elementId: groupId,
|
||||
detail: boundedDetail(
|
||||
`Group sub-mix failed for group ${groupId}: ${subMix.error ?? "unknown"}`,
|
||||
|
||||
@@ -82,11 +82,24 @@ export type AudioFailureReason =
|
||||
| "cancelled"
|
||||
| "internal";
|
||||
|
||||
export interface AudioSubprocessEvidence {
|
||||
exitCode?: number;
|
||||
signal?: string;
|
||||
terminationReason?: "exit" | "abort" | "deadline" | "inactivity" | "spawn_error";
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type AudioFailureDiagnostic = Pick<
|
||||
AudioProcessingFailure,
|
||||
"stage" | "reason" | "owner" | "retryable" | "subprocess"
|
||||
>;
|
||||
|
||||
export interface AudioProcessingFailure {
|
||||
stage: AudioFailureStage;
|
||||
reason: AudioFailureReason;
|
||||
owner: "user" | "system";
|
||||
retryable: boolean;
|
||||
subprocess?: AudioSubprocessEvidence;
|
||||
elementId?: string;
|
||||
/** Bounded diagnostic text; never includes the authored source URL/path. */
|
||||
detail: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CaptureWarning } from "../types.js";
|
||||
import { cloneCaptureWarning } from "./captureWarning.js";
|
||||
|
||||
describe("cloneCaptureWarning", () => {
|
||||
@@ -25,3 +26,33 @@ describe("cloneCaptureWarning", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates nested audio subprocess evidence in warning snapshots", () => {
|
||||
const source: CaptureWarning = {
|
||||
code: "audio_processing_failed",
|
||||
message: "failed",
|
||||
details: {
|
||||
audioFailures: [
|
||||
{
|
||||
stage: "prepare",
|
||||
reason: "ffmpeg_failed",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
subprocess: { exitCode: 7, terminationReason: "exit" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const copy = cloneCaptureWarning(source);
|
||||
const evidence = copy.details?.audioFailures?.[0]?.subprocess;
|
||||
if (!evidence) throw new Error("missing copied evidence");
|
||||
evidence.exitCode = 8;
|
||||
copy.details?.audioFailures?.push({
|
||||
stage: "mix",
|
||||
reason: "internal",
|
||||
owner: "system",
|
||||
retryable: false,
|
||||
});
|
||||
expect(source.details?.audioFailures).toHaveLength(1);
|
||||
expect(source.details?.audioFailures?.[0]?.subprocess?.exitCode).toBe(7);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ export function cloneCaptureWarning<T extends CaptureWarning>(warning: T): T {
|
||||
details: warning.details
|
||||
? {
|
||||
...warning.details,
|
||||
audioFailures: warning.details.audioFailures?.map((failure) => ({
|
||||
...failure,
|
||||
...(failure.subprocess ? { subprocess: { ...failure.subprocess } } : {}),
|
||||
})),
|
||||
sources: warning.details.sources ? [...warning.details.sources] : undefined,
|
||||
failureReasons: warning.details.failureReasons
|
||||
? [...warning.details.failureReasons]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* The engine's page contract. Any web page that wants to be rendered
|
||||
* as video must expose `window.__hf` implementing the HfProtocol interface.
|
||||
*/
|
||||
import type { AudioFailureDiagnostic } from "./services/audioMixer.types.js";
|
||||
import type { Fps } from "@hyperframes/core";
|
||||
import type { ChromeMemoryStats } from "./services/chromeMemorySampler.js";
|
||||
import type { MotionBlurOptions } from "./services/motionBlur.js";
|
||||
@@ -41,6 +42,7 @@ export interface CaptureWarning {
|
||||
sources?: string[];
|
||||
pendingCompositionIds?: string[];
|
||||
timeoutMs?: number;
|
||||
audioFailures?: AudioFailureDiagnostic[];
|
||||
failureReasons?: string[];
|
||||
failureStages?: string[];
|
||||
failureOwner?: "user" | "system";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { audioFailureDiagnostics } from "@hyperframes/engine";
|
||||
/**
|
||||
* Activity A of the distributed render pipeline.
|
||||
*
|
||||
@@ -337,6 +338,7 @@ export function applyDistributedAudioWarningPolicy(
|
||||
message: `Audio mix failed; output would be video-only: ${audioError}`,
|
||||
details: {
|
||||
mediaType: "audio",
|
||||
audioFailures: audioFailureDiagnostics(audioFailures),
|
||||
failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))],
|
||||
failureStages: [...new Set(audioFailures.map((failure) => failure.stage))],
|
||||
failureOwner,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { AudioFailureDiagnostic } from "@hyperframes/engine";
|
||||
|
||||
const ENCODER_INTERRUPTED = "ENCODER_INTERRUPTED" as const;
|
||||
|
||||
interface EncoderFailureResult {
|
||||
error?: string;
|
||||
failureReason?: "external_interruption";
|
||||
audioFailures?: AudioFailureDiagnostic[];
|
||||
}
|
||||
|
||||
/** A host/process lifecycle interruption that is safe to retry on a fresh producer. */
|
||||
@@ -14,16 +17,23 @@ export class EncoderInterruptedError extends Error {
|
||||
/** Retained inside the producer for diagnostics; never copied to the wire envelope. */
|
||||
readonly diagnosticMessage: string;
|
||||
|
||||
constructor(prefix: string, diagnosticMessage: string) {
|
||||
readonly audioFailures: AudioFailureDiagnostic[];
|
||||
|
||||
constructor(
|
||||
prefix: string,
|
||||
diagnosticMessage: string,
|
||||
audioFailures: AudioFailureDiagnostic[] = [],
|
||||
) {
|
||||
super(`${prefix}: encoder process was interrupted by the host lifecycle`);
|
||||
this.name = "EncoderInterruptedError";
|
||||
this.diagnosticMessage = diagnosticMessage;
|
||||
this.audioFailures = audioFailures;
|
||||
}
|
||||
}
|
||||
|
||||
export function encoderFailureError(prefix: string, result: EncoderFailureResult): Error {
|
||||
const message = `${prefix}: ${result.error ?? "unknown encoder failure"}`;
|
||||
return result.failureReason === "external_interruption"
|
||||
? new EncoderInterruptedError(prefix, message)
|
||||
? new EncoderInterruptedError(prefix, message, result.audioFailures)
|
||||
: new Error(message);
|
||||
}
|
||||
|
||||
@@ -177,12 +177,29 @@ describe("runAudioStage", () => {
|
||||
reason: "external_interruption",
|
||||
owner: "system",
|
||||
retryable: true,
|
||||
detail: "ffmpeg handled signal 15",
|
||||
detail: "private ffmpeg handled signal 15",
|
||||
elementId: "private-element",
|
||||
subprocess: { exitCode: 255, terminationReason: "exit" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(runAudioStage(makeInput())).rejects.toBeInstanceOf(EncoderInterruptedError);
|
||||
const pending = runAudioStage(makeInput());
|
||||
await expect(pending).rejects.toBeInstanceOf(EncoderInterruptedError);
|
||||
await expect(pending).rejects.toMatchObject({
|
||||
audioFailures: [
|
||||
{
|
||||
stage: "mix",
|
||||
reason: "external_interruption",
|
||||
owner: "system",
|
||||
retryable: true,
|
||||
subprocess: { exitCode: 255, terminationReason: "exit" },
|
||||
},
|
||||
],
|
||||
});
|
||||
await pending.catch((error: EncoderInterruptedError) => {
|
||||
expect(JSON.stringify(error.audioFailures)).not.toContain("private");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set audioError when there is no audio to mix", async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
MIXED_AUDIO_FILENAME,
|
||||
audioFailureDiagnostics,
|
||||
processCompositionAudio,
|
||||
type AudioProcessingFailure,
|
||||
} from "@hyperframes/engine";
|
||||
@@ -64,6 +65,13 @@ export interface AudioStageResult {
|
||||
audioFailures?: AudioProcessingFailure[];
|
||||
}
|
||||
|
||||
function triggerFirst(
|
||||
failures: readonly AudioProcessingFailure[] | undefined,
|
||||
trigger: AudioProcessingFailure,
|
||||
): AudioProcessingFailure[] {
|
||||
return [trigger, ...(failures ?? []).filter((failure) => failure !== trigger)];
|
||||
}
|
||||
|
||||
export async function runAudioStage(input: AudioStageInput): Promise<AudioStageResult> {
|
||||
const {
|
||||
projectDir,
|
||||
@@ -140,6 +148,7 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
|
||||
throw encoderFailureError("Audio processing failed", {
|
||||
error: interrupted.detail,
|
||||
failureReason: "external_interruption",
|
||||
audioFailures: audioFailureDiagnostics(triggerFirst(audioResult.failures, interrupted)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { audioFailureDiagnostics } from "@hyperframes/engine";
|
||||
// fallow-ignore-file unused-type circular-dependency code-duplication complexity
|
||||
/**
|
||||
* Render Orchestrator Service
|
||||
@@ -3387,6 +3388,7 @@ async function executeRenderPipeline(input: {
|
||||
message: `Audio mix failed; output would be video-only: ${audioResult.audioError}`,
|
||||
details: {
|
||||
mediaType: "audio",
|
||||
audioFailures: audioFailureDiagnostics(audioFailures),
|
||||
failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))],
|
||||
failureStages: [...new Set(audioFailures.map((failure) => failure.stage))],
|
||||
failureOwner,
|
||||
|
||||
Reference in New Issue
Block a user