diff --git a/lib/ads/video/encode.ts b/lib/ads/video/encode.ts index aeede36..4aaf348 100644 --- a/lib/ads/video/encode.ts +++ b/lib/ads/video/encode.ts @@ -98,6 +98,16 @@ export function mp4Args(o: Mp4EncodeOptions): string[] { } if (o.audioPath) { + // `apad` extends the audio with silence indefinitely, and `-shortest` then + // trims the result to the video's five seconds — so the track is exactly as + // long as the picture. + // + // Without the pad, a narration shorter than the ad (which every narration + // is: a five-second read is about two and a half seconds of speech) left an + // audio stream that ended early. Validation requires the two durations to + // match within one AAC frame and rejected every narrated render, and a + // player handed a short track is entitled to stop at its end. + args.push("-af", "apad"); args.push("-c:a", "aac", "-profile:a", "aac_low", "-ar", String(AAC_SAMPLE_RATE), "-b:a", "128k", "-ac", "2", "-shortest"); } else { args.push("-an"); diff --git a/tests/ads-video-compose-validate.test.ts b/tests/ads-video-compose-validate.test.ts index 6a834b4..800efd7 100644 --- a/tests/ads-video-compose-validate.test.ts +++ b/tests/ads-video-compose-validate.test.ts @@ -187,6 +187,23 @@ describe("encoder arguments carry the media contract", () => { expect(narrated[narrated.indexOf("-ar") + 1]).toBe("48000"); }); + it("pads a narration to the length of the picture", () => { + // Every narration is shorter than its ad — a five-second read is about two + // and a half seconds of speech. Without the pad the audio stream ended + // early, which failed the duration check on every narrated render and left + // players entitled to stop at the end of the track. + const a = mp4Args({ + framePattern: "f-%04d.png", + audioPath: "/tmp/narration.mp3", + outPath: "/tmp/out.mp4", + profile: "master_1080p", + videoKbps: 4000, + }).join(" "); + expect(a).toContain("-af apad"); + // apad alone runs forever; -shortest is what trims it back to the video. + expect(a).toContain("-shortest"); + }); + it("stream-copies into HLS rather than re-encoding", () => { const hls = hlsArgs({ inPath: "/tmp/720.mp4", outDir: "/tmp/hls", name: "720p" }); // Re-encoding would move the keyframes and break the boundaries the MP4 was