From 866f364054641eeef43929ce7677fc025e64be7c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 14:52:37 +0000 Subject: [PATCH] Declare the codecs the renditions actually have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multivariant playlist hardcoded CODECS="avc1.640028,mp4a.40.2" for every rendition. Both halves were wrong, and I only found it by fetching a shipped playlist and probing the file it points at. The level: shipped renditions probe as H.264 High @ 3.1, which is avc1.64001F. 0x28 is level 4.0. A player reads CODECS to choose a decoder configuration before it fetches a segment, so the declaration is a promise about the bitstream, and this one was not kept. The audio: every ad rendered so far is silent and carries a video stream only, yet each rendition advertised an AAC-LC track. An audio codec named in CODECS is an audio track the player expects; a strict one (Safari/AVPlayer) can allocate a decoder and wait for media that never arrives. Both are now derived from ffprobe on the encoded rendition rather than written down in advance: avcCodecString maps the reported profile name back to its profile_idc and formats avc1.PPCCLL, and mp4a.40.2 is appended only when the file actually has an audio stream. The recorded codecs on the hls asset row match what the playlist declares, instead of a third hardcoded copy. The pipeline test now asserts against the real encode that every declared codec matches /^avc1\\.[0-9A-F]{6}$/ and that no silent rendition mentions mp4a. Already-rendered revisions keep their incorrect playlist. Nothing reads it yet — there is no decision endpoint and no property serving HLS — and re-rendering them means bumping RENDERER_VERSION, which by design invalidates every cached encode. Worth doing before anything streams these, not worth seven hours of re-encoding today. Co-Authored-By: Claude Opus 5 (1M context) --- lib/ads/video/encode.ts | 39 +++++++++++++++++++++++- lib/ads/video/render.ts | 21 ++++++++++--- lib/ads/video/validate.ts | 2 ++ tests/ads-video-compose-validate.test.ts | 28 ++++++++++++++++- tests/ads-video-pipeline.test.ts | 11 +++++++ 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/lib/ads/video/encode.ts b/lib/ads/video/encode.ts index 431056f..aeede36 100644 --- a/lib/ads/video/encode.ts +++ b/lib/ads/video/encode.ts @@ -152,11 +152,48 @@ export function hlsArgs(o: HlsOptions): string[] { ]; } +/** + * RFC 6381 codec string for an H.264 stream: avc1.PPCCLL. + * + * PP is profile_idc, CC the constraint flags, LL the level, each as two hex + * digits. ffprobe reports the profile by name and the level as an integer, so + * the name is mapped back to its idc. + * + * This is derived from the encoded file rather than hardcoded because the + * declaration is load-bearing: a player reads CODECS to choose a decoder + * configuration before it fetches a segment, and a wrong level is a promise + * about the bitstream that the bitstream does not keep. + */ +const PROFILE_IDC: Record = { + "Constrained Baseline": 66, + Baseline: 66, + Main: 77, + Extended: 88, + High: 100, + "High 10": 110, + "High 4:2:2": 122, + "High 4:4:4 Predictive": 244, +}; + +export function avcCodecString(profile: string | undefined, level: number | undefined): string { + const idc = PROFILE_IDC[profile ?? ""] ?? 100; + const lvl = typeof level === "number" && level > 0 ? level : 31; + const hex = (n: number) => n.toString(16).padStart(2, "0").toUpperCase(); + // Constraint flags are 0 for the profiles we produce; libx264 sets none that + // belong in this field for High. + return `avc1.${hex(idc)}00${hex(lvl)}`; +} + +/** AAC-LC, appended only when a rendition actually carries an audio track. */ +export const AAC_LC_CODEC = "mp4a.40.2"; + /** * The multivariant playlist. * * Written by hand rather than by ffmpeg's var_stream_map because the bandwidth - * and codec declarations have to describe the renditions we actually produced, + * and codec declarations have to describe the renditions we actually produced + * — including not advertising an audio codec for a silent ad, which is a + * promise a player will wait on, * and because EXT-X-INDEPENDENT-SEGMENTS is deliberately absent: our segments * open on a keyframe but are not independently decodable in the sense that tag * asserts, and claiming it would be a lie a player acts on. diff --git a/lib/ads/video/render.ts b/lib/ads/video/render.ts index a5f39e8..4050394 100644 --- a/lib/ads/video/render.ts +++ b/lib/ads/video/render.ts @@ -18,6 +18,8 @@ import { import { composeDocument, type ComposeAssets } from "./compose"; import { validateSnapshot, type VideoDesignSnapshot } from "./snapshot"; import { + AAC_LC_CODEC, + avcCodecString, encodeAudioCompanion, encodeMp4, extractPoster, @@ -232,19 +234,30 @@ export async function renderPreroll(args: { for (const profile of ["mp4_720p", "mp4_480p"] as const) { const spec = videoProfile(profile); const name = `${spec.height}p`; - await packageHls({ inPath: path.join(outDir, `${profile}.mp4`), outDir: hlsDir, name }); + const renditionPath = path.join(outDir, `${profile}.mp4`); + await packageHls({ inPath: renditionPath, outDir: hlsDir, name }); const playlist = await readFile(path.join(hlsDir, `${name}.m3u8`), "utf8"); problems.push(...validateMediaPlaylist(playlist)); - const mp4Size = statSync(path.join(outDir, `${profile}.mp4`)).size; + // Declare what this rendition actually is. Both halves used to be + // hardcoded and both were wrong: the level said 4.0 for a stream encoded + // at 3.1, and every silent ad advertised an AAC track it did not have. + // A player reads CODECS before fetching a segment, so an audio codec named + // here is an audio track it will wait for. + const probe = await probeMedia(renditionPath); + const v = probe.streams.find((st) => st.codec_type === "video"); + const hasAudio = probe.streams.some((st) => st.codec_type === "audio"); + const codecs = [avcCodecString(v?.profile, v?.level), ...(hasAudio ? [AAC_LC_CODEC] : [])].join(","); + + const mp4Size = statSync(renditionPath).size; renditions.push({ name, width: spec.width!, height: spec.height!, // Peak bandwidth, derived from the rendition we actually produced. bandwidth: Math.round((mp4Size * 8) / 5), - codecs: "avc1.640028,mp4a.40.2", + codecs, }); } @@ -263,7 +276,7 @@ export async function renderPreroll(args: { width: null, height: null, durationMs: null, - codecs: "avc1.640028,mp4a.40.2", + codecs: renditions.map((r) => r.codecs).join(" "), validation: { ok: true, problems: [] }, extraFiles: hlsFiles.filter((f) => f !== masterPath), }); diff --git a/lib/ads/video/validate.ts b/lib/ads/video/validate.ts index 0728524..cf8c2a1 100644 --- a/lib/ads/video/validate.ts +++ b/lib/ads/video/validate.ts @@ -30,6 +30,8 @@ export type ProbeStream = { avg_frame_rate?: string; /** Present only with -count_frames; it is a decode, not a header read. */ nb_read_frames?: string; + /** h264 level as an integer, e.g. 31 for level 3.1. */ + level?: number; duration?: string; sample_rate?: string; channels?: number; diff --git a/tests/ads-video-compose-validate.test.ts b/tests/ads-video-compose-validate.test.ts index c507632..ceafa86 100644 --- a/tests/ads-video-compose-validate.test.ts +++ b/tests/ads-video-compose-validate.test.ts @@ -7,7 +7,7 @@ import { timeline, } from "@/lib/ads/video/compose"; import type { VideoDesignSnapshot } from "@/lib/ads/video/snapshot"; -import { GOP_FRAMES, hlsArgs, mp4Args, multivariantPlaylist, posterArgs } from "@/lib/ads/video/encode"; +import { AAC_LC_CODEC, avcCodecString, GOP_FRAMES, hlsArgs, mp4Args, multivariantPlaylist, posterArgs } from "@/lib/ads/video/encode"; import { evaluateProbe, parseRational, @@ -195,6 +195,32 @@ describe("encoder arguments carry the media contract", () => { expect(poster[poster.indexOf("-ss") + 1]).toBe("2"); }); +it("derives the avc1 codec string from what was actually encoded", () => { + // Shipped renditions probe as High @ 3.1. The hardcoded string said level + // 4.0 (0x28), which is a promise about the bitstream it does not keep. + expect(avcCodecString("High", 31)).toBe("avc1.64001F"); + expect(avcCodecString("High", 40)).toBe("avc1.640028"); + expect(avcCodecString("Main", 31)).toBe("avc1.4D001F"); + expect(avcCodecString("Constrained Baseline", 30)).toBe("avc1.42001E"); + // Unknown input falls back to High @ 3.1 rather than emitting nonsense. + expect(avcCodecString(undefined, undefined)).toBe("avc1.64001F"); + }); + + it("never advertises audio for a silent rendition", () => { + // A player reads CODECS before fetching a segment, so an audio codec named + // here is an audio track it will wait for. Every ad is silent today. + const silent = multivariantPlaylist([ + { name: "720p", width: 1280, height: 720, bandwidth: 700000, codecs: avcCodecString("High", 31) }, + ]); + expect(silent).toContain('CODECS="avc1.64001F"'); + expect(silent).not.toContain(AAC_LC_CODEC); + + const narrated = multivariantPlaylist([ + { name: "720p", width: 1280, height: 720, bandwidth: 700000, codecs: `${avcCodecString("High", 31)},${AAC_LC_CODEC}` }, + ]); + expect(narrated).toContain(AAC_LC_CODEC); + }); + it("does not claim independent segments in the multivariant playlist", () => { const m = multivariantPlaylist([ { name: "720p", width: 1280, height: 720, bandwidth: 2_400_000, codecs: "avc1.640028" }, diff --git a/tests/ads-video-pipeline.test.ts b/tests/ads-video-pipeline.test.ts index d90b9f2..dcdde2a 100644 --- a/tests/ads-video-pipeline.test.ts +++ b/tests/ads-video-pipeline.test.ts @@ -148,6 +148,17 @@ describe("a snapshot renders to validated media", () => { // Never claimed, because we do not guarantee it. expect(master).not.toContain("EXT-X-INDEPENDENT-SEGMENTS"); + // The CODECS declaration must describe what was actually encoded. These + // renditions are silent, so naming an audio codec would be an audio + // track a player waits for and never receives; and the level has to be + // the one in the bitstream, not a guess. + expect(master).not.toContain("mp4a"); + const declared = [...master.matchAll(/CODECS="([^"]+)"/g)].map((m) => m[1]); + expect(declared.length).toBe(2); + for (const codec of declared) { + expect(codec, `codecs=${codec}`).toMatch(/^avc1\.[0-9A-F]{6}$/); + } + expect(byProfile.poster.byteSize).toBeGreaterThan(0); expect(byProfile.poster.contentType).toBe("image/webp"); },