mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
perf(producer): the audio mix runs while capture starts its browsers (#6632)
* perf(producer): the audio mix runs while capture starts its browsers * test(producer): master-bus test stops the render at capture setup
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveConfig } from "@hyperframes/engine";
|
||||
import { createRenderJob, executeRenderJob } from "./renderOrchestrator.js";
|
||||
|
||||
// Entry: executeRenderJob. The audio mix is held open; worker resolution (capture setup) records
|
||||
// itself and then stops the render, so the test sees what ran while audio was still mixing.
|
||||
const trace = vi.hoisted(() => ({ events: [] as string[], releaseAudio: () => {} }));
|
||||
|
||||
vi.mock("./render/shared.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./render/shared.js")>()),
|
||||
createMemorySampler: () => ({
|
||||
peakRssBytes: () => 0,
|
||||
peakHeapUsedBytes: () => 0,
|
||||
stop: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("./fileServer.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./fileServer.js")>()),
|
||||
createFileServer: async () => ({
|
||||
url: "http://127.0.0.1:1",
|
||||
port: 1,
|
||||
close: () => {},
|
||||
addPreHeadScript: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("./render/stages/compileStage.js", () => ({
|
||||
runCompileStage: async () => ({
|
||||
compiled: {
|
||||
html: "<div></div>",
|
||||
hasShaderTransitions: false,
|
||||
renderModeHints: { reasons: [] },
|
||||
},
|
||||
composition: { width: 16, height: 16, duration: 40 / 30, videos: [], images: [], audios: [] },
|
||||
deviceScaleFactor: 1,
|
||||
outputWidth: 16,
|
||||
outputHeight: 16,
|
||||
compileOnlyMs: 0,
|
||||
forceScreenshot: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./render/stages/probeStage.js", () => ({
|
||||
runProbeStage: async ({ compiled }: { compiled: object }) => ({
|
||||
compiled,
|
||||
fileServer: null,
|
||||
probeSession: null,
|
||||
lastBrowserConsole: [],
|
||||
duration: 40 / 30,
|
||||
totalFrames: 40,
|
||||
browserProbeMs: 0,
|
||||
beginFrameStalled: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./render/stages/extractVideosStage.js", () => ({
|
||||
runExtractVideosStage: async () => ({
|
||||
extractionResult: null,
|
||||
frameLookup: null,
|
||||
videoReadinessSkipIds: [],
|
||||
videoMetadataHints: [],
|
||||
nativeHdrVideoIds: new Set(),
|
||||
videoTransfers: new Map(),
|
||||
nativeHdrImageIds: new Set(),
|
||||
imageTransfers: new Map(),
|
||||
hdrImageSrcPaths: new Map(),
|
||||
imageColorSpaces: [],
|
||||
videoExtractMs: 0,
|
||||
failureToEnforce: null,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./render/stages/audioStage.js", () => ({
|
||||
runAudioStage: ({ workDir }: { workDir: string }) =>
|
||||
new Promise((resolve) => {
|
||||
trace.events.push("audio started");
|
||||
trace.releaseAudio = () => {
|
||||
trace.events.push("audio done");
|
||||
resolve({
|
||||
audioOutputPath: join(workDir, "audio.wav"),
|
||||
hasAudio: false,
|
||||
audioProcessMs: 0,
|
||||
});
|
||||
};
|
||||
}),
|
||||
}));
|
||||
vi.mock("./render/captureCost.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./render/captureCost.js")>()),
|
||||
resolveRenderWorkerCount: () => {
|
||||
trace.events.push("workers resolved");
|
||||
throw new Error("stop after capture setup");
|
||||
},
|
||||
}));
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function until(condition: () => boolean): Promise<boolean> {
|
||||
for (let turn = 0; turn < 5_000 && !condition(); turn++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
return condition();
|
||||
}
|
||||
|
||||
it("sets up capture while the audio mixes, and a failure waits for the mix", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-audio-overlap-"));
|
||||
dirs.push(root);
|
||||
writeFileSync(join(root, "index.html"), "<div></div>");
|
||||
const job = createRenderJob({
|
||||
fps: 30,
|
||||
quality: "draft",
|
||||
format: "mp4",
|
||||
hdrMode: "force-sdr",
|
||||
producerConfig: resolveConfig({ enablePageSideCompositing: false }),
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
});
|
||||
let failure: unknown;
|
||||
const render = executeRenderJob(job, root, join(root, "frames")).catch((error: unknown) => {
|
||||
failure = error;
|
||||
});
|
||||
|
||||
expect(await until(() => trace.events.includes("workers resolved"))).toBe(true);
|
||||
expect(trace.events).toEqual(["audio started", "workers resolved"]);
|
||||
await until(() => failure !== undefined);
|
||||
expect(failure).toBeUndefined();
|
||||
|
||||
trace.releaseAudio();
|
||||
await render;
|
||||
expect(trace.events).toEqual(["audio started", "workers resolved", "audio done"]);
|
||||
expect(String(failure)).toContain("stop after capture setup");
|
||||
});
|
||||
@@ -72,6 +72,13 @@ vi.mock("./render/stages/audioStage.js", () => ({
|
||||
throw new Error("audio stage reached");
|
||||
},
|
||||
}));
|
||||
// The mix runs while capture sets up, so the render stops at setup's first step instead.
|
||||
vi.mock("./fileServer.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./fileServer.js")>()),
|
||||
createFileServer: async () => {
|
||||
throw new Error("capture setup reached");
|
||||
},
|
||||
}));
|
||||
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
@@ -89,7 +96,7 @@ it("the local renderer gives the shared audio stage its master chain and video c
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
});
|
||||
await expect(executeRenderJob(job, root, join(root, "output.mp4"))).rejects.toThrow(
|
||||
"audio stage reached",
|
||||
"capture setup reached",
|
||||
);
|
||||
expect(seen.audioInput?.masterFxChain).toBe(MASTER_CHAIN);
|
||||
expect(seen.audioInput?.totalFrames).toBe(30);
|
||||
|
||||
@@ -3016,6 +3016,8 @@ async function executeRenderPipeline(input: {
|
||||
let captureCompositionHeight: number | undefined;
|
||||
let captureTotalFrames: number | undefined;
|
||||
const perfStages: Record<string, number> = {};
|
||||
// The audio mix runs while capture sets up; a failure in between still waits for it.
|
||||
let pendingAudio: Promise<unknown> | undefined;
|
||||
const hdrDiagnostics: HdrDiagnostics = {
|
||||
videoExtractionFailures: 0,
|
||||
imageDecodeFailures: 0,
|
||||
@@ -3516,7 +3518,7 @@ async function executeRenderPipeline(input: {
|
||||
code: "process_audio",
|
||||
});
|
||||
|
||||
const audioResult = await observeRenderStage(
|
||||
const audioStage = observeRenderStage(
|
||||
observability,
|
||||
"audio_process",
|
||||
{ audioCount: composition.audios.length },
|
||||
@@ -3536,40 +3538,7 @@ async function executeRenderPipeline(input: {
|
||||
log,
|
||||
}),
|
||||
);
|
||||
const { audioOutputPath, hasAudio } = audioResult;
|
||||
perfStages.audioProcessMs = audioResult.audioProcessMs;
|
||||
recordLimiterAttenuation(job, audioResult.audioLoweredDb);
|
||||
if (audioResult.audioError) {
|
||||
const audioFailures = audioResult.audioFailures ?? [];
|
||||
const failureOwner =
|
||||
audioFailures.length === 0
|
||||
? undefined
|
||||
: audioFailures.some((failure) => failure.owner === "system")
|
||||
? "system"
|
||||
: "user";
|
||||
const retryable =
|
||||
audioFailures.length === 0
|
||||
? undefined
|
||||
: audioFailures.every((failure) => failure.retryable);
|
||||
applyRenderWarningPolicy(
|
||||
job,
|
||||
[
|
||||
{
|
||||
code: "audio_processing_failed",
|
||||
message: `Audio mix failed; output would be video-only: ${audioResult.audioError}`,
|
||||
details: {
|
||||
mediaType: "audio",
|
||||
audioFailures: audioFailureDiagnostics(audioFailures),
|
||||
failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))],
|
||||
failureStages: [...new Set(audioFailures.map((failure) => failure.stage))],
|
||||
failureOwner,
|
||||
retryable,
|
||||
},
|
||||
},
|
||||
],
|
||||
log,
|
||||
);
|
||||
}
|
||||
pendingAudio = audioStage.catch(() => undefined);
|
||||
|
||||
// ── Stage 4: Frame capture ──────────────────────────────────────────
|
||||
const stage4Start = Date.now();
|
||||
@@ -4584,6 +4553,42 @@ async function executeRenderPipeline(input: {
|
||||
const effectiveQuality = job.config.crf ?? preset.quality;
|
||||
const effectiveBitrate = job.config.crf != null ? undefined : job.config.videoBitrate;
|
||||
|
||||
const audioResult = await audioStage;
|
||||
const { audioOutputPath, hasAudio } = audioResult;
|
||||
perfStages.audioProcessMs = audioResult.audioProcessMs;
|
||||
recordLimiterAttenuation(job, audioResult.audioLoweredDb);
|
||||
if (audioResult.audioError) {
|
||||
const audioFailures = audioResult.audioFailures ?? [];
|
||||
const failureOwner =
|
||||
audioFailures.length === 0
|
||||
? undefined
|
||||
: audioFailures.some((failure) => failure.owner === "system")
|
||||
? "system"
|
||||
: "user";
|
||||
const retryable =
|
||||
audioFailures.length === 0
|
||||
? undefined
|
||||
: audioFailures.every((failure) => failure.retryable);
|
||||
applyRenderWarningPolicy(
|
||||
job,
|
||||
[
|
||||
{
|
||||
code: "audio_processing_failed",
|
||||
message: `Audio mix failed; output would be video-only: ${audioResult.audioError}`,
|
||||
details: {
|
||||
mediaType: "audio",
|
||||
audioFailures: audioFailureDiagnostics(audioFailures),
|
||||
failureReasons: [...new Set(audioFailures.map((failure) => failure.reason))],
|
||||
failureStages: [...new Set(audioFailures.map((failure) => failure.stage))],
|
||||
failureOwner,
|
||||
retryable,
|
||||
},
|
||||
},
|
||||
],
|
||||
log,
|
||||
);
|
||||
}
|
||||
|
||||
resetCaptureAttemptProgress(job);
|
||||
|
||||
// ── Z-ordered multi-layer compositing ─────────────────────────────────
|
||||
@@ -5488,6 +5493,7 @@ async function executeRenderPipeline(input: {
|
||||
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
|
||||
await eventPublisher.flush();
|
||||
} catch (caught) {
|
||||
await pendingAudio;
|
||||
const error = tooSlowError ?? caught;
|
||||
if (!tooSlowError && (error instanceof RenderCancelledError || executionSignal?.aborted)) {
|
||||
job.error = error instanceof Error ? error.message : "render_cancelled";
|
||||
|
||||
Reference in New Issue
Block a user