Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion lib/ads/video/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
"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.
Expand Down
21 changes: 17 additions & 4 deletions lib/ads/video/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
}

Expand All @@ -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),
});
Expand Down
2 changes: 2 additions & 0 deletions lib/ads/video/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 27 additions & 1 deletion tests/ads-video-compose-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" },
Expand Down
11 changes: 11 additions & 0 deletions tests/ads-video-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
Expand Down