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"); },