import { AudioCodec, MediaContainer, type Metadata, VideoCodec } from "./types"; export function decodePath(path: string) { return path .split("/") .map((segment) => decodeURIComponent(segment)) .join("/"); } export function encodePath(path: string) { return path .split("/") .map((segment) => encodeURIComponent(segment)) .join("/"); } //scale the video down to keep the requested bitrate plausible for its resolution. each width is the //largest one still reasonable at that bitrate ceiling; above the last rung the source is left alone const RESOLUTION_LADDER: { upToBitrate: number; width: number }[] = [ { upToBitrate: 400_000, width: 640 }, { upToBitrate: 800_000, width: 854 }, { upToBitrate: 2_500_000, width: 1280 }, { upToBitrate: 5_000_000, width: 1920 }, { upToBitrate: 12_000_000, width: 2560 }, ]; //shared because the server needs it to build the scale filter and the client needs it to predict the //output resolution the codec level is derived from - the two strings have to agree export function ladderMaxWidth(videoBitrate: number): number | undefined { return RESOLUTION_LADDER.find((rung) => videoBitrate <= rung.upToBitrate)?.width; } //-b:v alone is plain ABR with no VBV cap, so the real peak runs above the target. the level limits are //compared against the peak, and under-stating the level is the failure mode that breaks playback //mid-stream, so allow headroom const BITRATE_PEAK_MARGIN = 1.5; export interface VideoStreamInfo { //source dimensions, before the ladder scales them down sourceWidth: number; sourceHeight: number; frameRate: number; interlaced: boolean; //the requested bitrate, which is what selects the ladder rung bitrate: number; } export function videoStreamInfo(metadata: Metadata | undefined, videoBitrate: number): VideoStreamInfo | undefined { if (!metadata?.videoWidth || !metadata.videoHeight || !metadata.frameRate) return undefined; return { sourceWidth: metadata.videoWidth, sourceHeight: metadata.videoHeight, frameRate: metadata.frameRate, interlaced: metadata.interlaced === true, //transcoding never raises the bitrate above the source's, so the level must be derived from the //capped value. clamping twice changes nothing, which lets the server pass its already-capped number bitrate: metadata.videoBitrate ? Math.min(videoBitrate, metadata.videoBitrate) : videoBitrate, }; } //mirrors -vf scale='if(gt(iw,W),W,iw)':-2 - below the rung width the source passes through untouched function transcodeOutputSize(sourceWidth: number, sourceHeight: number, videoBitrate: number) { const maxWidth = ladderMaxWidth(videoBitrate); if (maxWidth === undefined || sourceWidth <= maxWidth) return { width: sourceWidth, height: sourceHeight }; //-2 keeps the aspect ratio at a multiple of two. rounding up rather than to nearest can only ever //over-state the level by one rung, which is the harmless direction return { width: maxWidth, height: Math.ceil((sourceHeight * maxWidth) / sourceWidth / 2) * 2 }; } //Codec levels are picked by the encoder from the resolution, frame rate and bitrate, so they can be //derived rather than guessed. Each table below is the level list from the relevant spec; the limits are //committee-chosen constants with no generating formula, so they are data. Verified against the strings //ffmpeg computes for its own output (its DASH muxer writes them into the MPD). // //The tables are ordered lowest level first and the first satisfying entry wins, which is what makes //arbitrary resolutions and fractional frame rates fall out without special cases. //ITU-T H.264 Annex A, Table A-1. FS is the frame size in macroblocks, MBPS = FS * fps. MaxBR is scaled //by cpbBrVclFactor, which is 1250 for High profile (the profile the encoder is pinned to). const H264_LEVELS = [ { idc: 0x0a, maxMBPS: 1485, maxFS: 99, maxBR: 64 }, { idc: 0x0b, maxMBPS: 3000, maxFS: 396, maxBR: 192 }, { idc: 0x0c, maxMBPS: 6000, maxFS: 396, maxBR: 384 }, { idc: 0x0d, maxMBPS: 11880, maxFS: 396, maxBR: 768 }, { idc: 0x14, maxMBPS: 11880, maxFS: 396, maxBR: 2000 }, { idc: 0x15, maxMBPS: 19800, maxFS: 792, maxBR: 4000 }, { idc: 0x16, maxMBPS: 20250, maxFS: 1620, maxBR: 4000 }, { idc: 0x1e, maxMBPS: 40500, maxFS: 1620, maxBR: 10000 }, { idc: 0x1f, maxMBPS: 108000, maxFS: 3600, maxBR: 14000 }, { idc: 0x20, maxMBPS: 216000, maxFS: 5120, maxBR: 20000 }, { idc: 0x28, maxMBPS: 245760, maxFS: 8192, maxBR: 20000 }, { idc: 0x29, maxMBPS: 245760, maxFS: 8192, maxBR: 50000 }, { idc: 0x2a, maxMBPS: 522240, maxFS: 8704, maxBR: 50000 }, { idc: 0x32, maxMBPS: 589824, maxFS: 22080, maxBR: 135000 }, { idc: 0x33, maxMBPS: 983040, maxFS: 36864, maxBR: 240000 }, { idc: 0x34, maxMBPS: 2073600, maxFS: 36864, maxBR: 240000 }, { idc: 0x3c, maxMBPS: 4177920, maxFS: 139264, maxBR: 240000 }, { idc: 0x3d, maxMBPS: 8355840, maxFS: 139264, maxBR: 480000 }, { idc: 0x3e, maxMBPS: 16711680, maxFS: 139264, maxBR: 800000 }, ]; //ITU-T H.265 Annex A, Tables A.6 (picture size and sample rate) and A.7 (Main tier bit rate). //general_level_idc is the level times 30. const H265_LEVELS = [ { idc: 30, maxLumaPs: 36864, maxLumaSr: 552960, maxBR: 128 }, { idc: 60, maxLumaPs: 122880, maxLumaSr: 3686400, maxBR: 1500 }, { idc: 63, maxLumaPs: 245760, maxLumaSr: 7372800, maxBR: 3000 }, { idc: 90, maxLumaPs: 552960, maxLumaSr: 16588800, maxBR: 6000 }, { idc: 93, maxLumaPs: 983040, maxLumaSr: 33177600, maxBR: 10000 }, { idc: 120, maxLumaPs: 2228224, maxLumaSr: 66846720, maxBR: 12000 }, { idc: 123, maxLumaPs: 2228224, maxLumaSr: 133693440, maxBR: 20000 }, { idc: 150, maxLumaPs: 8912896, maxLumaSr: 267386880, maxBR: 25000 }, { idc: 153, maxLumaPs: 8912896, maxLumaSr: 534773760, maxBR: 40000 }, { idc: 156, maxLumaPs: 8912896, maxLumaSr: 1069547520, maxBR: 60000 }, { idc: 180, maxLumaPs: 35651584, maxLumaSr: 1069547520, maxBR: 60000 }, { idc: 183, maxLumaPs: 35651584, maxLumaSr: 2139095040, maxBR: 120000 }, { idc: 186, maxLumaPs: 35651584, maxLumaSr: 4278190080, maxBR: 240000 }, ]; //VP9 Bitstream & Decoding Process Specification v0.6, Annex A. "code" is the level times 10. const VP9_LEVELS = [ { code: 10, maxSr: 829440, maxPs: 36864, maxBR: 200, maxDim: 512 }, { code: 11, maxSr: 2764800, maxPs: 73728, maxBR: 800, maxDim: 768 }, { code: 20, maxSr: 4608000, maxPs: 122880, maxBR: 1800, maxDim: 960 }, { code: 21, maxSr: 9216000, maxPs: 245760, maxBR: 3600, maxDim: 1344 }, { code: 30, maxSr: 20736000, maxPs: 552960, maxBR: 7200, maxDim: 2048 }, { code: 31, maxSr: 36864000, maxPs: 983040, maxBR: 12000, maxDim: 2752 }, { code: 40, maxSr: 83558400, maxPs: 2228224, maxBR: 18000, maxDim: 4160 }, { code: 41, maxSr: 160432128, maxPs: 2228224, maxBR: 30000, maxDim: 4160 }, { code: 50, maxSr: 311951360, maxPs: 8912896, maxBR: 60000, maxDim: 8384 }, { code: 51, maxSr: 588251136, maxPs: 8912896, maxBR: 120000, maxDim: 8384 }, { code: 52, maxSr: 1176502272, maxPs: 8912896, maxBR: 180000, maxDim: 8384 }, { code: 60, maxSr: 1176502272, maxPs: 35651584, maxBR: 180000, maxDim: 16832 }, { code: 61, maxSr: 2353004544, maxPs: 35651584, maxBR: 240000, maxDim: 16832 }, { code: 62, maxSr: 4706009088, maxPs: 35651584, maxBR: 480000, maxDim: 16832 }, ]; //AV1 Bitstream & Decoding Process Specification, Annex A, Table A.1. seq_level_idx is //(major - 2) * 4 + minor, so the numbering has deliberate gaps. mbps is the Main tier column. const AV1_LEVELS = [ { idx: 0, maxPicSize: 147456, maxH: 2048, maxV: 1152, maxDisplayRate: 4423680, mbps: 1.5 }, { idx: 1, maxPicSize: 278784, maxH: 2816, maxV: 1584, maxDisplayRate: 8363520, mbps: 3.0 }, { idx: 4, maxPicSize: 665856, maxH: 4352, maxV: 2448, maxDisplayRate: 19975680, mbps: 6.0 }, { idx: 5, maxPicSize: 1065024, maxH: 5504, maxV: 3096, maxDisplayRate: 31950720, mbps: 10.0 }, { idx: 8, maxPicSize: 2359296, maxH: 6144, maxV: 3456, maxDisplayRate: 70778880, mbps: 12.0 }, { idx: 9, maxPicSize: 2359296, maxH: 6144, maxV: 3456, maxDisplayRate: 141557760, mbps: 20.0 }, { idx: 12, maxPicSize: 8912896, maxH: 8192, maxV: 4352, maxDisplayRate: 267386880, mbps: 30.0 }, { idx: 13, maxPicSize: 8912896, maxH: 8192, maxV: 4352, maxDisplayRate: 534773760, mbps: 40.0 }, { idx: 14, maxPicSize: 8912896, maxH: 8192, maxV: 4352, maxDisplayRate: 1069547520, mbps: 60.0 }, { idx: 16, maxPicSize: 35651584, maxH: 16384, maxV: 8704, maxDisplayRate: 1069547520, mbps: 60.0 }, { idx: 17, maxPicSize: 35651584, maxH: 16384, maxV: 8704, maxDisplayRate: 2139095040, mbps: 100.0 }, { idx: 18, maxPicSize: 35651584, maxH: 16384, maxV: 8704, maxDisplayRate: 4278190080, mbps: 160.0 }, ]; //the stream as the encoder will see it, which is what the level limits are written against function encodedStream(video: VideoStreamInfo) { const size = transcodeOutputSize(video.sourceWidth, video.sourceHeight, video.bitrate); return { size, pixels: size.width * size.height, //the encoder sees one frame per field once yadif has run fps: video.interlaced ? video.frameRate * 2 : video.frameRate, kbps: (video.bitrate * BITRATE_PEAK_MARGIN) / 1000, }; } export function videoCodecToken(videoCodec: VideoCodec, video: VideoStreamInfo | undefined): string { const stream = video && encodedStream(video); //an input beyond the last level, or missing source metadata, takes the highest level. claiming more than //the stream needs can only make a weak client refuse a type it would have managed, and it then falls back //to progressive playback; claiming less lets it start and fail part-way through function pickLevel(table: T[], fits: (level: T, s: ReturnType) => boolean): T { const highest = table[table.length - 1]; if (!stream) return highest; return table.find((entry) => fits(entry, stream)) ?? highest; } switch (videoCodec) { case VideoCodec.av1: { const level = pickLevel( AV1_LEVELS, (l, { size, pixels, fps, kbps }) => pixels <= l.maxPicSize && size.width <= l.maxH && size.height <= l.maxV && pixels * fps <= l.maxDisplayRate && kbps / 1000 <= l.mbps, ); //profile 0 (Main), Main tier, 8 bit return `av01.0.${level.idx.toString().padStart(2, "0")}M.08`; } case VideoCodec.h264: { const level = pickLevel(H264_LEVELS, (l, { size, fps, kbps }) => { const mbWidth = Math.ceil(size.width / 16); const mbHeight = Math.ceil(size.height / 16); const fs = mbWidth * mbHeight; return ( fs <= l.maxFS && fs * fps <= l.maxMBPS && //Annex A.3.1 also caps each dimension individually mbWidth <= Math.sqrt(l.maxFS * 8) && mbHeight <= Math.sqrt(l.maxFS * 8) && kbps <= l.maxBR * 1.25 ); }); //High profile (profile_idc 100 = 0x64) with no constraint flags set return `avc1.6400${level.idc.toString(16).padStart(2, "0")}`; } case VideoCodec.h265: { const level = pickLevel( H265_LEVELS, (l, { size, pixels, fps, kbps }) => pixels <= l.maxLumaPs && pixels * fps <= l.maxLumaSr && size.width <= Math.sqrt(l.maxLumaPs * 8) && size.height <= Math.sqrt(l.maxLumaPs * 8) && kbps <= l.maxBR, ); //Main profile: profile_space 0, profile_idc 1, compatibility flags 0x60000000 written //bit-reversed as hex ("6"), Main tier ("L"). B0 = progressive, non-packed, frame-only. return `hev1.1.6.L${level.idc}.B0`; } case VideoCodec.vp9: { const level = pickLevel( VP9_LEVELS, (l, { size, pixels, fps, kbps }) => pixels <= l.maxPs && pixels * fps <= l.maxSr && Math.max(size.width, size.height) <= l.maxDim && kbps <= l.maxBR, ); //profile 0 (4:2:0), 8 bit return `vp09.00.${level.code.toString().padStart(2, "0")}.08`; } default: throw new Error(`Unsupported video codec: ${videoCodec}`); } } export function audioCodecToken(audioCodec: AudioCodec): string { switch (audioCodec) { case AudioCodec.opus: return "opus"; case AudioCodec.aac: //bare "aac" is not a codec string any browser accepts. 0x40 is the MP4RA object type for //ISO/IEC 14496-3 audio and 2 is the AAC-LC audio object type within it return "mp4a.40.2"; case AudioCodec.vorbis: return "vorbis"; case AudioCodec.mp3: //"mp3" is not a registered codec string and was rejected everywhere. the MP4RA object type for //MPEG-1 layer III audio is 0x6B (0x69 is the MPEG-2 variant), and only means anything inside mp4 - //the mp3 container names no codec at all. chromium plays mp4a.6B progressively but refuses it in a //SourceBuffer, which is a distinction the old token could not express because it failed both return "mp4a.6B"; case AudioCodec.flac: return "flac"; default: throw new Error(`Unsupported audio codec: ${audioCodec}`); } } //the MPEG Audio byte stream format spec is explicit that the codecs parameter MUST NOT be used with //audio/mpeg, and there is exactly one codec it could ever name anyway. every other container needs it export function containerNamesCodecs(container: MediaContainer): boolean { return container !== MediaContainer.mp3; } //an audio-only stream is not a video resource, and the video/ prefix on one is not merely cosmetic: //it changes what the byte stream format expects to find export function containerMimeType(container: MediaContainer, hasVideo: boolean): string { const prefix = hasVideo ? "video" : "audio"; switch (container) { case MediaContainer.mp4: return `${prefix}/mp4`; case MediaContainer.webm: return `${prefix}/webm`; case MediaContainer.mp3: return "audio/mpeg"; case MediaContainer.ogg: return "audio/ogg"; default: throw new Error(`Unsupported container: ${container}`); } } export function audioCodecHasBitrate(audioCodec: AudioCodec): boolean { return audioCodec !== AudioCodec.flac; } export function createRFC6381String( container: MediaContainer, videoCodec: VideoCodec, audioCodec: AudioCodec, video?: VideoStreamInfo, ): string { const hasVideo = videoCodec !== VideoCodec.none; const mimeType = containerMimeType(container, hasVideo); if (!containerNamesCodecs(container)) return mimeType; const codecs = hasVideo ? `${videoCodecToken(videoCodec, video)}, ${audioCodecToken(audioCodec)}` : audioCodecToken(audioCodec); return `${mimeType}; codecs="${codecs}"`; }