diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index a61d2b0796a..be5e5205af9 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -1756,7 +1756,7 @@ How it uses the machine, and when it gives up: | `--strict-variables` | off | Fail rather than warn on an undeclared or mistyped variable key | | `--batch-concurrency` / `--batch-fail-fast` | `1` / off | Batch rows at once, and whether the first failure stops the rest | | `--debug` | off | Keep intermediates and write diagnostics under the Producer `.debug` directory | -| `--quiet` | off | Less output | +| `--quiet` | off | Less output; warnings, including delivery audio gain reductions, remain visible | | `--skill` | — | Record which authoring workflow started this render, in usage telemetry | Low-memory detection reads **host** RAM, not cgroup or container limits, so diff --git a/packages/producer/src/services/render/audioPadTrim.integration.test.ts b/packages/producer/src/services/render/audioPadTrim.integration.test.ts index 65ce5c966c2..8c188953841 100644 --- a/packages/producer/src/services/render/audioPadTrim.integration.test.ts +++ b/packages/producer/src/services/render/audioPadTrim.integration.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { getFfmpegBinary, muxVideoWithAudio } from "@hyperframes/engine"; import { padOrTrimAudioToVideoFrameCount } from "./audioPadTrim.js"; +import { runAudioStage } from "./stages/audioStage.js"; const dirs: string[] = []; afterEach(() => dirs.splice(0).forEach((dir) => rmSync(dir, { recursive: true, force: true }))); @@ -45,6 +46,86 @@ const hasFfmpeg = (() => { })(); describe.skipIf(!hasFfmpeg)("audio pad real-media packet contract", () => { + it.each([ + { frameCount: 91, fps: { num: 30, den: 1 } }, + { frameCount: 31, fps: { num: 30000, den: 1001 } }, + ])( + "copies frame-fitted AAC without another encode at $fps.num/$fps.den", + async ({ frameCount, fps }) => { + const dir = mkdtempSync(join(tmpdir(), "hf-aac-copy-")); + dirs.push(dir); + const source = join(dir, "source.wav"); + const duration = (frameCount * fps.den) / fps.num; + execFileSync(getFfmpegBinary(), [ + "-nostdin", + "-v", + "error", + "-f", + "lavfi", + "-i", + `aevalsrc=0.15*sin(2*PI*73*t)+0.02*sin(2*PI*997*t)|0.12*sin(2*PI*101*t)+0.03*sin(2*PI*1601*t):s=48000:d=${duration + 0.1}:c=stereo`, + "-c:a", + "pcm_f32le", + source, + ]); + const mixed = await runAudioStage({ + projectDir: dir, + workDir: dir, + compiledDir: dir, + totalFrames: frameCount, + fps, + ffmpegProcessTimeout: 30_000, + audioGain: 1, + audios: [ + { + id: "stereo", + src: source, + start: 0, + end: duration, + mediaStart: 0, + volume: 1, + type: "audio", + }, + ], + abortSignal: undefined, + assertNotAborted: () => {}, + }); + expect(mixed.hasAudio, mixed.audioError).toBe(true); + const normalized = await padOrTrimAudioToVideoFrameCount({ + audioPath: mixed.audioOutputPath, + outputPath: join(dir, "normalized.m4a"), + videoPath: join(dir, "video.mp4"), + probeVideoFrameInfo: async () => ({ frameCount, fpsNum: fps.num, fpsDen: fps.den }), + }); + expect(normalized.success, normalized.error).toBe(true); + expect(normalized.operation).toBe("copy"); + const packetHashes = (path: string) => + execFileSync( + "ffprobe", + [ + "-v", + "error", + "-select_streams", + "a:0", + "-show_packets", + "-show_data_hash", + "sha256", + "-show_entries", + "packet=data_hash", + "-of", + "csv=p=0", + path, + ], + { encoding: "utf8" }, + ) + .split("\n") + .filter((line) => line.includes("SHA256:")); + const originalPackets = packetHashes(mixed.audioOutputPath); + expect(originalPackets.length).toBeGreaterThan(1); + expect(packetHashes(normalized.outputPath)).toEqual(originalPackets); + }, + ); + it("normalizes a tiny raw-ADTS pad without an oversized terminal packet", async () => { const dir = mkdtempSync(join(tmpdir(), "hf-pad-")); dirs.push(dir); diff --git a/packages/producer/src/services/render/stages/assembleStage.test.ts b/packages/producer/src/services/render/stages/assembleStage.test.ts index faa18b14f70..7170e188a1d 100644 --- a/packages/producer/src/services/render/stages/assembleStage.test.ts +++ b/packages/producer/src/services/render/stages/assembleStage.test.ts @@ -26,7 +26,8 @@ vi.mock("../shared.js", () => ({ updateJobStatus: vi.fn(), })); -import { runAssembleStage } from "./assembleStage.js"; +import { recordLimiterAttenuation, runAssembleStage } from "./assembleStage.js"; +import { createConsoleLogger } from "../../../logger.js"; import { EncoderInterruptedError } from "../encoderInterruption.js"; function makeInput(overrides: Partial = {}): AssembleStageInput { @@ -316,7 +317,7 @@ describe("runAssembleStage limiter honesty", () => { await runAssembleStage(input); expect(input.job.audioLoweredDb).toBe(1.44); - expect(logger.info).toHaveBeenCalledWith("Audio lowered by 1.4 dB to stay under −1 dBTP", { + expect(logger.warn).toHaveBeenCalledWith("Audio lowered by 1.4 dB to stay under −1 dBTP", { audioLoweredDb: 1.44, }); }); @@ -326,4 +327,24 @@ describe("runAssembleStage limiter honesty", () => { await runAssembleStage(input); expect(input.job).not.toHaveProperty("audioLoweredDb"); }); + + it("keeps an audible gain change visible with the CLI's quiet logger", () => { + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const info = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const input = makeInput(); + input.job.config.logger = createConsoleLogger("warn"); + + recordLimiterAttenuation(input.job, 1.44); + + expect(warning).toHaveBeenCalledWith( + '[WARN] Audio lowered by 1.4 dB to stay under −1 dBTP {"audioLoweredDb":1.44}', + ); + expect(input.job.audioLoweredDb).toBe(1.44); + expect(info).not.toHaveBeenCalled(); + } finally { + warning.mockRestore(); + info.mockRestore(); + } + }); }); diff --git a/packages/producer/src/services/render/stages/assembleStage.ts b/packages/producer/src/services/render/stages/assembleStage.ts index 3fb4505a92e..4373240ffcd 100644 --- a/packages/producer/src/services/render/stages/assembleStage.ts +++ b/packages/producer/src/services/render/stages/assembleStage.ts @@ -61,7 +61,7 @@ export function recordLimiterAttenuation(job: RenderJob, audioLoweredDb: number if (audioLoweredDb === undefined) return; job.audioLoweredDb = audioLoweredDb; const ceiling = `−${Math.abs(AAC_DELIVERY_TRUE_PEAK_DBFS)} dBTP`; - (job.config.logger ?? defaultLogger).info( + (job.config.logger ?? defaultLogger).warn( `Audio lowered by ${audioLoweredDb.toFixed(1)} dB to stay under ${ceiling}`, { audioLoweredDb }, );