From 7a61de64f686ece1052d1abd810abf2c72c812cf Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 19:19:57 +0000 Subject: [PATCH] Pad the narration to the length of the picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every narrated render failed validation. The voiceover for a five-second ad is about two and a half seconds of speech, so the audio stream ended early and the duration check — which requires picture and sound to agree within one AAC frame — rejected all of them: audio duration expected 5000ms, got 2346.000ms (drift 2654.000ms) apad extends the audio with silence indefinitely and -shortest then trims the result back to the video's five seconds, so the track is exactly as long as the picture. This is worth doing beyond the check: a player handed a track that ends early is entitled to stop there. Co-Authored-By: Claude Opus 5 (1M context) --- lib/ads/video/encode.ts | 10 ++++++++++ tests/ads-video-compose-validate.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/ads/video/encode.ts b/lib/ads/video/encode.ts index aeede367..4aaf348b 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 6a834b4b..800efd76 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