utils.ts
⎇
Raw
1import { AudioCodec, MediaContainer, type Metadata, VideoCodec } from "./types";
2
3export function decodePath(path: string) {
4 return path
5 .split("/")
6 .map((segment) => decodeURIComponent(segment))
7 .join("/");
8}
9
10export function encodePath(path: string) {
11 return path
12 .split("/")
13 .map((segment) => encodeURIComponent(segment))
14 .join("/");
15}
16
17//scale the video down to keep the requested bitrate plausible for its resolution. each width is the
18//largest one still reasonable at that bitrate ceiling; above the last rung the source is left alone
19const RESOLUTION_LADDER: { upToBitrate: number; width: number }[] = [
20 { upToBitrate: 400_000, width: 640 },
21 { upToBitrate: 800_000, width: 854 },
22 { upToBitrate: 2_500_000, width: 1280 },
23 { upToBitrate: 5_000_000, width: 1920 },
24 { upToBitrate: 12_000_000, width: 2560 },
25];
26
27//shared because the server needs it to build the scale filter and the client needs it to predict the
28//output resolution the codec level is derived from - the two strings have to agree
29export function ladderMaxWidth(videoBitrate: number): number | undefined {
30 return RESOLUTION_LADDER.find((rung) => videoBitrate <= rung.upToBitrate)?.width;
31}
32
33//-b:v alone is plain ABR with no VBV cap, so the real peak runs above the target. the level limits are
34//compared against the peak, and under-stating the level is the failure mode that breaks playback
35//mid-stream, so allow headroom
36const BITRATE_PEAK_MARGIN = 1.5;
37
38export interface VideoStreamInfo {
39 //source dimensions, before the ladder scales them down
40 sourceWidth: number;
41 sourceHeight: number;
42 frameRate: number;
43 interlaced: boolean;
44 //the requested bitrate, which is what selects the ladder rung
45 bitrate: number;
46}
47
48export function videoStreamInfo(metadata: Metadata | undefined, videoBitrate: number): VideoStreamInfo | undefined {
49 if (!metadata?.videoWidth || !metadata.videoHeight || !metadata.frameRate) return undefined;
50 return {
51 sourceWidth: metadata.videoWidth,
52 sourceHeight: metadata.videoHeight,
53 frameRate: metadata.frameRate,
54 interlaced: metadata.interlaced === true,
55 //transcoding never raises the bitrate above the source's, so the level must be derived from the
56 //capped value. clamping twice changes nothing, which lets the server pass its already-capped number
57 bitrate: metadata.videoBitrate ? Math.min(videoBitrate, metadata.videoBitrate) : videoBitrate,
58 };
59}
60
61//mirrors -vf scale='if(gt(iw,W),W,iw)':-2 - below the rung width the source passes through untouched
62function transcodeOutputSize(sourceWidth: number, sourceHeight: number, videoBitrate: number) {
63 const maxWidth = ladderMaxWidth(videoBitrate);
64 if (maxWidth === undefined || sourceWidth <= maxWidth) return { width: sourceWidth, height: sourceHeight };
65 //-2 keeps the aspect ratio at a multiple of two. rounding up rather than to nearest can only ever
66 //over-state the level by one rung, which is the harmless direction
67 return { width: maxWidth, height: Math.ceil((sourceHeight * maxWidth) / sourceWidth / 2) * 2 };
68}
69
70//Codec levels are picked by the encoder from the resolution, frame rate and bitrate, so they can be
71//derived rather than guessed. Each table below is the level list from the relevant spec; the limits are
72//committee-chosen constants with no generating formula, so they are data. Verified against the strings
73//ffmpeg computes for its own output (its DASH muxer writes them into the MPD).
74//
75//The tables are ordered lowest level first and the first satisfying entry wins, which is what makes
76//arbitrary resolutions and fractional frame rates fall out without special cases.
77
78//ITU-T H.264 Annex A, Table A-1. FS is the frame size in macroblocks, MBPS = FS * fps. MaxBR is scaled
79//by cpbBrVclFactor, which is 1250 for High profile (the profile the encoder is pinned to).
80const H264_LEVELS = [
81 { idc: 0x0a, maxMBPS: 1485, maxFS: 99, maxBR: 64 },
82 { idc: 0x0b, maxMBPS: 3000, maxFS: 396, maxBR: 192 },
83 { idc: 0x0c, maxMBPS: 6000, maxFS: 396, maxBR: 384 },
84 { idc: 0x0d, maxMBPS: 11880, maxFS: 396, maxBR: 768 },
85 { idc: 0x14, maxMBPS: 11880, maxFS: 396, maxBR: 2000 },
86 { idc: 0x15, maxMBPS: 19800, maxFS: 792, maxBR: 4000 },
87 { idc: 0x16, maxMBPS: 20250, maxFS: 1620, maxBR: 4000 },
88 { idc: 0x1e, maxMBPS: 40500, maxFS: 1620, maxBR: 10000 },
89 { idc: 0x1f, maxMBPS: 108000, maxFS: 3600, maxBR: 14000 },
90 { idc: 0x20, maxMBPS: 216000, maxFS: 5120, maxBR: 20000 },
91 { idc: 0x28, maxMBPS: 245760, maxFS: 8192, maxBR: 20000 },
92 { idc: 0x29, maxMBPS: 245760, maxFS: 8192, maxBR: 50000 },
93 { idc: 0x2a, maxMBPS: 522240, maxFS: 8704, maxBR: 50000 },
94 { idc: 0x32, maxMBPS: 589824, maxFS: 22080, maxBR: 135000 },
95 { idc: 0x33, maxMBPS: 983040, maxFS: 36864, maxBR: 240000 },
96 { idc: 0x34, maxMBPS: 2073600, maxFS: 36864, maxBR: 240000 },
97 { idc: 0x3c, maxMBPS: 4177920, maxFS: 139264, maxBR: 240000 },
98 { idc: 0x3d, maxMBPS: 8355840, maxFS: 139264, maxBR: 480000 },
99 { idc: 0x3e, maxMBPS: 16711680, maxFS: 139264, maxBR: 800000 },
100];
101
102//ITU-T H.265 Annex A, Tables A.6 (picture size and sample rate) and A.7 (Main tier bit rate).
103//general_level_idc is the level times 30.
104const H265_LEVELS = [
105 { idc: 30, maxLumaPs: 36864, maxLumaSr: 552960, maxBR: 128 },
106 { idc: 60, maxLumaPs: 122880, maxLumaSr: 3686400, maxBR: 1500 },
107 { idc: 63, maxLumaPs: 245760, maxLumaSr: 7372800, maxBR: 3000 },
108 { idc: 90, maxLumaPs: 552960, maxLumaSr: 16588800, maxBR: 6000 },
109 { idc: 93, maxLumaPs: 983040, maxLumaSr: 33177600, maxBR: 10000 },
110 { idc: 120, maxLumaPs: 2228224, maxLumaSr: 66846720, maxBR: 12000 },
111 { idc: 123, maxLumaPs: 2228224, maxLumaSr: 133693440, maxBR: 20000 },
112 { idc: 150, maxLumaPs: 8912896, maxLumaSr: 267386880, maxBR: 25000 },
113 { idc: 153, maxLumaPs: 8912896, maxLumaSr: 534773760, maxBR: 40000 },
114 { idc: 156, maxLumaPs: 8912896, maxLumaSr: 1069547520, maxBR: 60000 },
115 { idc: 180, maxLumaPs: 35651584, maxLumaSr: 1069547520, maxBR: 60000 },
116 { idc: 183, maxLumaPs: 35651584, maxLumaSr: 2139095040, maxBR: 120000 },
117 { idc: 186, maxLumaPs: 35651584, maxLumaSr: 4278190080, maxBR: 240000 },
118];
119
120//VP9 Bitstream & Decoding Process Specification v0.6, Annex A. "code" is the level times 10.
121const VP9_LEVELS = [
122 { code: 10, maxSr: 829440, maxPs: 36864, maxBR: 200, maxDim: 512 },
123 { code: 11, maxSr: 2764800, maxPs: 73728, maxBR: 800, maxDim: 768 },
124 { code: 20, maxSr: 4608000, maxPs: 122880, maxBR: 1800, maxDim: 960 },
125 { code: 21, maxSr: 9216000, maxPs: 245760, maxBR: 3600, maxDim: 1344 },
126 { code: 30, maxSr: 20736000, maxPs: 552960, maxBR: 7200, maxDim: 2048 },
127 { code: 31, maxSr: 36864000, maxPs: 983040, maxBR: 12000, maxDim: 2752 },
128 { code: 40, maxSr: 83558400, maxPs: 2228224, maxBR: 18000, maxDim: 4160 },
129 { code: 41, maxSr: 160432128, maxPs: 2228224, maxBR: 30000, maxDim: 4160 },
130 { code: 50, maxSr: 311951360, maxPs: 8912896, maxBR: 60000, maxDim: 8384 },
131 { code: 51, maxSr: 588251136, maxPs: 8912896, maxBR: 120000, maxDim: 8384 },
132 { code: 52, maxSr: 1176502272, maxPs: 8912896, maxBR: 180000, maxDim: 8384 },
133 { code: 60, maxSr: 1176502272, maxPs: 35651584, maxBR: 180000, maxDim: 16832 },
134 { code: 61, maxSr: 2353004544, maxPs: 35651584, maxBR: 240000, maxDim: 16832 },
135 { code: 62, maxSr: 4706009088, maxPs: 35651584, maxBR: 480000, maxDim: 16832 },
136];
137
138//AV1 Bitstream & Decoding Process Specification, Annex A, Table A.1. seq_level_idx is
139//(major - 2) * 4 + minor, so the numbering has deliberate gaps. mbps is the Main tier column.
140const AV1_LEVELS = [
141 { idx: 0, maxPicSize: 147456, maxH: 2048, maxV: 1152, maxDisplayRate: 4423680, mbps: 1.5 },
142 { idx: 1, maxPicSize: 278784, maxH: 2816, maxV: 1584, maxDisplayRate: 8363520, mbps: 3.0 },
143 { idx: 4, maxPicSize: 665856, maxH: 4352, maxV: 2448, maxDisplayRate: 19975680, mbps: 6.0 },
144 { idx: 5, maxPicSize: 1065024, maxH: 5504, maxV: 3096, maxDisplayRate: 31950720, mbps: 10.0 },
145 { idx: 8, maxPicSize: 2359296, maxH: 6144, maxV: 3456, maxDisplayRate: 70778880, mbps: 12.0 },
146 { idx: 9, maxPicSize: 2359296, maxH: 6144, maxV: 3456, maxDisplayRate: 141557760, mbps: 20.0 },
147 { idx: 12, maxPicSize: 8912896, maxH: 8192, maxV: 4352, maxDisplayRate: 267386880, mbps: 30.0 },
148 { idx: 13, maxPicSize: 8912896, maxH: 8192, maxV: 4352, maxDisplayRate: 534773760, mbps: 40.0 },
149 { idx: 14, maxPicSize: 8912896, maxH: 8192, maxV: 4352, maxDisplayRate: 1069547520, mbps: 60.0 },
150 { idx: 16, maxPicSize: 35651584, maxH: 16384, maxV: 8704, maxDisplayRate: 1069547520, mbps: 60.0 },
151 { idx: 17, maxPicSize: 35651584, maxH: 16384, maxV: 8704, maxDisplayRate: 2139095040, mbps: 100.0 },
152 { idx: 18, maxPicSize: 35651584, maxH: 16384, maxV: 8704, maxDisplayRate: 4278190080, mbps: 160.0 },
153];
154
155//the stream as the encoder will see it, which is what the level limits are written against
156function encodedStream(video: VideoStreamInfo) {
157 const size = transcodeOutputSize(video.sourceWidth, video.sourceHeight, video.bitrate);
158 return {
159 size,
160 pixels: size.width * size.height,
161 //the encoder sees one frame per field once yadif has run
162 fps: video.interlaced ? video.frameRate * 2 : video.frameRate,
163 kbps: (video.bitrate * BITRATE_PEAK_MARGIN) / 1000,
164 };
165}
166
167export function videoCodecToken(videoCodec: VideoCodec, video: VideoStreamInfo | undefined): string {
168 const stream = video && encodedStream(video);
169
170 //an input beyond the last level, or missing source metadata, takes the highest level. claiming more than
171 //the stream needs can only make a weak client refuse a type it would have managed, and it then falls back
172 //to progressive playback; claiming less lets it start and fail part-way through
173 function pickLevel<T>(table: T[], fits: (level: T, s: ReturnType<typeof encodedStream>) => boolean): T {
174 const highest = table[table.length - 1];
175 if (!stream) return highest;
176 return table.find((entry) => fits(entry, stream)) ?? highest;
177 }
178
179 switch (videoCodec) {
180 case VideoCodec.av1: {
181 const level = pickLevel(
182 AV1_LEVELS,
183 (l, { size, pixels, fps, kbps }) =>
184 pixels <= l.maxPicSize &&
185 size.width <= l.maxH &&
186 size.height <= l.maxV &&
187 pixels * fps <= l.maxDisplayRate &&
188 kbps / 1000 <= l.mbps,
189 );
190 //profile 0 (Main), Main tier, 8 bit
191 return `av01.0.${level.idx.toString().padStart(2, "0")}M.08`;
192 }
193 case VideoCodec.h264: {
194 const level = pickLevel(H264_LEVELS, (l, { size, fps, kbps }) => {
195 const mbWidth = Math.ceil(size.width / 16);
196 const mbHeight = Math.ceil(size.height / 16);
197 const fs = mbWidth * mbHeight;
198 return (
199 fs <= l.maxFS &&
200 fs * fps <= l.maxMBPS &&
201 //Annex A.3.1 also caps each dimension individually
202 mbWidth <= Math.sqrt(l.maxFS * 8) &&
203 mbHeight <= Math.sqrt(l.maxFS * 8) &&
204 kbps <= l.maxBR * 1.25
205 );
206 });
207 //High profile (profile_idc 100 = 0x64) with no constraint flags set
208 return `avc1.6400${level.idc.toString(16).padStart(2, "0")}`;
209 }
210 case VideoCodec.h265: {
211 const level = pickLevel(
212 H265_LEVELS,
213 (l, { size, pixels, fps, kbps }) =>
214 pixels <= l.maxLumaPs &&
215 pixels * fps <= l.maxLumaSr &&
216 size.width <= Math.sqrt(l.maxLumaPs * 8) &&
217 size.height <= Math.sqrt(l.maxLumaPs * 8) &&
218 kbps <= l.maxBR,
219 );
220 //Main profile: profile_space 0, profile_idc 1, compatibility flags 0x60000000 written
221 //bit-reversed as hex ("6"), Main tier ("L"). B0 = progressive, non-packed, frame-only.
222 return `hev1.1.6.L${level.idc}.B0`;
223 }
224 case VideoCodec.vp9: {
225 const level = pickLevel(
226 VP9_LEVELS,
227 (l, { size, pixels, fps, kbps }) =>
228 pixels <= l.maxPs &&
229 pixels * fps <= l.maxSr &&
230 Math.max(size.width, size.height) <= l.maxDim &&
231 kbps <= l.maxBR,
232 );
233 //profile 0 (4:2:0), 8 bit
234 return `vp09.00.${level.code.toString().padStart(2, "0")}.08`;
235 }
236 default:
237 throw new Error(`Unsupported video codec: ${videoCodec}`);
238 }
239}
240
241export function audioCodecToken(audioCodec: AudioCodec): string {
242 switch (audioCodec) {
243 case AudioCodec.opus:
244 return "opus";
245 case AudioCodec.aac:
246 //bare "aac" is not a codec string any browser accepts. 0x40 is the MP4RA object type for
247 //ISO/IEC 14496-3 audio and 2 is the AAC-LC audio object type within it
248 return "mp4a.40.2";
249 case AudioCodec.vorbis:
250 return "vorbis";
251 case AudioCodec.mp3:
252 //"mp3" is not a registered codec string and was rejected everywhere. the MP4RA object type for
253 //MPEG-1 layer III audio is 0x6B (0x69 is the MPEG-2 variant), and only means anything inside mp4 -
254 //the mp3 container names no codec at all. chromium plays mp4a.6B progressively but refuses it in a
255 //SourceBuffer, which is a distinction the old token could not express because it failed both
256 return "mp4a.6B";
257 case AudioCodec.flac:
258 return "flac";
259 default:
260 throw new Error(`Unsupported audio codec: ${audioCodec}`);
261 }
262}
263
264//the MPEG Audio byte stream format spec is explicit that the codecs parameter MUST NOT be used with
265//audio/mpeg, and there is exactly one codec it could ever name anyway. every other container needs it
266export function containerNamesCodecs(container: MediaContainer): boolean {
267 return container !== MediaContainer.mp3;
268}
269
270//an audio-only stream is not a video resource, and the video/ prefix on one is not merely cosmetic:
271//it changes what the byte stream format expects to find
272export function containerMimeType(container: MediaContainer, hasVideo: boolean): string {
273 const prefix = hasVideo ? "video" : "audio";
274 switch (container) {
275 case MediaContainer.mp4:
276 return `${prefix}/mp4`;
277 case MediaContainer.webm:
278 return `${prefix}/webm`;
279 case MediaContainer.mp3:
280 return "audio/mpeg";
281 case MediaContainer.ogg:
282 return "audio/ogg";
283 default:
284 throw new Error(`Unsupported container: ${container}`);
285 }
286}
287
288export function audioCodecHasBitrate(audioCodec: AudioCodec): boolean {
289 return audioCodec !== AudioCodec.flac;
290}
291
292export function createRFC6381String(
293 container: MediaContainer,
294 videoCodec: VideoCodec,
295 audioCodec: AudioCodec,
296 video?: VideoStreamInfo,
297): string {
298 const hasVideo = videoCodec !== VideoCodec.none;
299 const mimeType = containerMimeType(container, hasVideo);
300 if (!containerNamesCodecs(container)) return mimeType;
301
302 const codecs = hasVideo
303 ? `${videoCodecToken(videoCodec, video)}, ${audioCodecToken(audioCodec)}`
304 : audioCodecToken(audioCodec);
305 return `${mimeType}; codecs="${codecs}"`;
306}
307