utils.ts
| 1 | import { AudioCodec, MediaContainer, type Metadata, VideoCodec } from "./types"; |
| 2 | |
| 3 | export function decodePath(path: string) { |
| 4 | return path |
| 5 | .split("/") |
| 6 | .map((segment) => decodeURIComponent(segment)) |
| 7 | .join("/"); |
| 8 | } |
| 9 | |
| 10 | export 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 |
| 19 | const 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 |
| 29 | export 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 |
| 36 | const BITRATE_PEAK_MARGIN = 1.5; |
| 37 | |
| 38 | export 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 | |
| 48 | export 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 |
| 62 | function 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). |
| 80 | const 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. |
| 104 | const 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. |
| 121 | const 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. |
| 140 | const 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 |
| 156 | function 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 | |
| 167 | export 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 | |
| 241 | export 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 |
| 266 | export 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 |
| 272 | export 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 | |
| 288 | export function audioCodecHasBitrate(audioCodec: AudioCodec): boolean { |
| 289 | return audioCodec !== AudioCodec.flac; |
| 290 | } |
| 291 | |
| 292 | export 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 |