capabilities.ts
| 1 | import { AudioCodec, MediaContainer, VideoCodec } from "music-server-shared/types"; |
| 2 | import { |
| 3 | audioCodecToken, |
| 4 | containerMimeType, |
| 5 | containerNamesCodecs, |
| 6 | createRFC6381String, |
| 7 | ladderMaxWidth, |
| 8 | type VideoStreamInfo, |
| 9 | videoCodecToken, |
| 10 | } from "music-server-shared/utils"; |
| 11 | import { StreamingMode, VIDEO_BITRATES } from "./types"; |
| 12 | |
| 13 | export interface Support { |
| 14 | supported: boolean; |
| 15 | reason?: string; |
| 16 | } |
| 17 | |
| 18 | //what a transcoding profile would produce, which is all it takes to decide whether a browser can play it. |
| 19 | //deliberately the shape of AppOptions' two settings objects, so either can be passed straight in and a |
| 20 | //single field varied with a spread - no argument threading and no positional mix-ups at the call sites |
| 21 | export interface Combination { |
| 22 | container: MediaContainer; |
| 23 | streamingMode: StreamingMode; |
| 24 | audioCodec: AudioCodec; |
| 25 | videoCodec?: VideoCodec; |
| 26 | videoBitrate?: number; |
| 27 | } |
| 28 | |
| 29 | export const hasMediaSource = "MediaSource" in window; |
| 30 | export const DEFAULT_STREAMING_MODE = hasMediaSource ? StreamingMode.mse : StreamingMode.progressive; |
| 31 | |
| 32 | //the W3C MSE Byte Stream Format Registry is a closed list: ISO BMFF, WebM, MPEG-2 TS and MPEG Audio. a |
| 33 | //container missing from it cannot be fed to a SourceBuffer by any conforming browser, no matter how well |
| 34 | //that same browser plays it progressively - so this is a fact about the spec, not something to probe for |
| 35 | const MSE_BYTE_STREAM_FORMATS: MediaContainer[] = [MediaContainer.mp4, MediaContainer.webm, MediaContainer.mp3]; |
| 36 | |
| 37 | //audio codecs that are at least plausible in each container - ffmpeg refuses the rest outright, so there |
| 38 | //is no browser verdict to ask for |
| 39 | const CONTAINER_AUDIO_CODECS: Record<MediaContainer, AudioCodec[]> = { |
| 40 | [MediaContainer.mp4]: [AudioCodec.mp3, AudioCodec.opus, AudioCodec.aac, AudioCodec.flac], |
| 41 | [MediaContainer.webm]: [AudioCodec.opus, AudioCodec.vorbis], |
| 42 | [MediaContainer.ogg]: [AudioCodec.opus, AudioCodec.vorbis, AudioCodec.flac], |
| 43 | [MediaContainer.mp3]: [AudioCodec.mp3], |
| 44 | }; |
| 45 | |
| 46 | const CONTAINER_VIDEO_CODECS: Record<MediaContainer, VideoCodec[]> = { |
| 47 | [MediaContainer.mp4]: [VideoCodec.av1, VideoCodec.h264, VideoCodec.h265, VideoCodec.vp9], |
| 48 | [MediaContainer.webm]: [VideoCodec.av1, VideoCodec.vp9], |
| 49 | [MediaContainer.ogg]: [], |
| 50 | [MediaContainer.mp3]: [], |
| 51 | }; |
| 52 | |
| 53 | export function containerAudioCodecs(container: MediaContainer): AudioCodec[] { |
| 54 | return CONTAINER_AUDIO_CODECS[container]; |
| 55 | } |
| 56 | |
| 57 | //"none" is always an option: every container can carry audio alone |
| 58 | export function containerVideoCodecs(container: MediaContainer): VideoCodec[] { |
| 59 | return [...CONTAINER_VIDEO_CODECS[container], VideoCodec.none]; |
| 60 | } |
| 61 | |
| 62 | //actual values don't seem to matter, besides for a possible "smoothnes" verdict |
| 63 | //though any valid codec string should result in the same playability result for the same codec |
| 64 | const NOMINAL_FRAMERATE = 30; |
| 65 | const NOMINAL_UNSCALED_WIDTH = 3840; |
| 66 | |
| 67 | function nominalVideoStream(videoBitrate: number): VideoStreamInfo { |
| 68 | const width = ladderMaxWidth(videoBitrate) ?? NOMINAL_UNSCALED_WIDTH; |
| 69 | return { |
| 70 | sourceWidth: width, |
| 71 | sourceHeight: Math.round(width / (16 / 9) / 2) * 2, |
| 72 | frameRate: NOMINAL_FRAMERATE, |
| 73 | interlaced: false, |
| 74 | bitrate: videoBitrate, |
| 75 | }; |
| 76 | } |
| 77 | |
| 78 | let probeElement: HTMLVideoElement | undefined; |
| 79 | |
| 80 | function progressivelyPlayable(mimeType: string): boolean { |
| 81 | probeElement ??= document.createElement("video"); |
| 82 | return probeElement.canPlayType(mimeType) !== ""; |
| 83 | } |
| 84 | |
| 85 | //MediaSource.isTypeSupported is the cheap gate but a lenient one: it parses the type and checks the |
| 86 | //codecs in isolation, so it says yes to combinations the decoder then refuses. decodingInfo asks the |
| 87 | //actual decoder pipeline, per track, with the resolution and framerate attached |
| 88 | const decodingCache = new Map<string, { supported: boolean; smooth: boolean }>(); |
| 89 | const pending = new Map<string, Promise<void>>(); |
| 90 | |
| 91 | //the same key really does come up twice: progressive and buffered are both type "file", so initCapabilities |
| 92 | //enumerates every one of these configurations a second time |
| 93 | function startDecodingProbe(key: string, configuration: MediaDecodingConfiguration): void { |
| 94 | if (pending.has(key)) return; |
| 95 | const probe = navigator.mediaCapabilities |
| 96 | .decodingInfo(configuration) |
| 97 | .then((info) => { |
| 98 | decodingCache.set(key, { supported: info.supported, smooth: info.smooth }); |
| 99 | }) |
| 100 | .catch(() => { |
| 101 | //a configuration this browser cannot even parse tells us nothing beyond what the sync checks |
| 102 | //already established, so record the permissive answer rather than disabling a working option |
| 103 | decodingCache.set(key, { supported: true, smooth: true }); |
| 104 | }) |
| 105 | .finally(() => { |
| 106 | pending.delete(key); |
| 107 | }); |
| 108 | pending.set(key, probe); |
| 109 | } |
| 110 | |
| 111 | export function checkSupport(combination: Combination): Support { |
| 112 | const { container, streamingMode, audioCodec } = combination; |
| 113 | const videoCodec = combination.videoCodec ?? VideoCodec.none; |
| 114 | const hasVideo = videoCodec !== VideoCodec.none; |
| 115 | if (streamingMode === StreamingMode.mse) { |
| 116 | if (!hasMediaSource) return { supported: false, reason: "this browser has no MediaSource support" }; |
| 117 | if (!MSE_BYTE_STREAM_FORMATS.includes(container)) |
| 118 | return { |
| 119 | supported: false, |
| 120 | reason: `${container} has no registered MSE byte stream format, so no browser can stream it - pick progressive or buffered`, |
| 121 | }; |
| 122 | //measured against -live 1, -dash 1, -default_mode infer, explicit dispositions, forced cluster |
| 123 | //limits and both track orders: the append succeeds, updateend fires, and buffered stays empty |
| 124 | if (container === MediaContainer.webm && hasVideo) |
| 125 | return { |
| 126 | supported: false, |
| 127 | reason: "effectively unsupported by browsers in mse", |
| 128 | }; |
| 129 | } |
| 130 | |
| 131 | const video = hasVideo ? nominalVideoStream(combination.videoBitrate ?? 0) : undefined; |
| 132 | const mimeType = createRFC6381String(container, videoCodec, audioCodec, video); |
| 133 | |
| 134 | if (streamingMode === StreamingMode.mse) { |
| 135 | if (!MediaSource.isTypeSupported(mimeType)) |
| 136 | return { supported: false, reason: `the browser rejects ${mimeType} in a SourceBuffer` }; |
| 137 | } else if (!progressivelyPlayable(mimeType)) { |
| 138 | return { supported: false, reason: `the browser cannot play ${mimeType}` }; |
| 139 | } |
| 140 | |
| 141 | //decodingInfo needs one content type per track, which is why the tokens are built here rather than |
| 142 | //reusing the combined string above. a container that names no codec has nothing to ask about |
| 143 | if (!containerNamesCodecs(container)) return { supported: true }; |
| 144 | const configuration: MediaDecodingConfiguration = { |
| 145 | type: streamingMode === StreamingMode.mse ? "media-source" : "file", |
| 146 | audio: { contentType: `${containerMimeType(container, false)}; codecs="${audioCodecToken(audioCodec)}"` }, |
| 147 | ...(video |
| 148 | ? { |
| 149 | video: { |
| 150 | contentType: `${containerMimeType(container, true)}; codecs="${videoCodecToken(videoCodec, video)}"`, |
| 151 | width: video.sourceWidth, |
| 152 | height: video.sourceHeight, |
| 153 | bitrate: video.bitrate, |
| 154 | framerate: video.frameRate, |
| 155 | }, |
| 156 | } |
| 157 | : {}), |
| 158 | }; |
| 159 | |
| 160 | //keyed on the bitrate only when there is a video track, so an audio-only probe is not re-run once per |
| 161 | //resolution ladder rung for an answer that cannot depend on it |
| 162 | const key = `${configuration.type}|${configuration.audio?.contentType}|${configuration.video?.contentType}|${video?.bitrate}`; |
| 163 | const cached = decodingCache.get(key); |
| 164 | if (!cached) { |
| 165 | startDecodingProbe(key, configuration); |
| 166 | return { supported: true }; |
| 167 | } |
| 168 | if (!cached.supported) return { supported: false, reason: `the browser's decoders reject ${mimeType}` }; |
| 169 | if (!cached.smooth) |
| 170 | return { supported: true, reason: "supported, but the browser does not expect to decode this smoothly" }; |
| 171 | return { supported: true }; |
| 172 | } |
| 173 | |
| 174 | //a container is offerable when at least one codec combination inside it works in the current mode |
| 175 | function checkContainer(combination: Combination, videoCodecs: VideoCodec[]): Support { |
| 176 | let firstFailure: Support | undefined; |
| 177 | for (const videoCodec of videoCodecs) { |
| 178 | for (const audioCodec of containerAudioCodecs(combination.container)) { |
| 179 | const support = checkSupport({ ...combination, videoCodec, audioCodec }); |
| 180 | if (support.supported) return { supported: true }; |
| 181 | firstFailure ??= support; |
| 182 | } |
| 183 | } |
| 184 | return firstFailure ?? { supported: false, reason: `${combination.container} offers no usable codecs` }; |
| 185 | } |
| 186 | |
| 187 | //an audio-only profile must not be told mp4 works because mp4 video works, only because mp4 audio does - |
| 188 | //and an absent videoCodec is exactly what makes a profile audio-only |
| 189 | export function checkContainerSupport(combination: Combination): Support { |
| 190 | return checkContainer( |
| 191 | combination, |
| 192 | combination.videoCodec === undefined ? [VideoCodec.none] : containerVideoCodecs(combination.container), |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | function usableAudioCodecs(combination: Combination): AudioCodec[] { |
| 197 | return containerAudioCodecs(combination.container).filter( |
| 198 | (audioCodec) => checkSupport({ ...combination, audioCodec }).supported, |
| 199 | ); |
| 200 | } |
| 201 | |
| 202 | function usableVideoCodecs(combination: Combination): VideoCodec[] { |
| 203 | return containerVideoCodecs(combination.container).filter( |
| 204 | (videoCodec) => checkContainer(combination, [videoCodec]).supported, |
| 205 | ); |
| 206 | } |
| 207 | |
| 208 | function usableContainers(combination: Combination): MediaContainer[] { |
| 209 | return Object.values(MediaContainer).filter( |
| 210 | (container) => checkContainerSupport({ ...combination, container }).supported, |
| 211 | ); |
| 212 | } |
| 213 | |
| 214 | //used to decide whether the video section is worth showing at all - it is not when nothing but "none" would |
| 215 | //be selectable |
| 216 | export function selectableVideoCodecs(combination: Combination): VideoCodec[] { |
| 217 | return usableVideoCodecs(combination).filter((videoCodec) => videoCodec !== VideoCodec.none); |
| 218 | } |
| 219 | |
| 220 | function firstUsable<T>(current: T, usable: T[]): T | undefined { |
| 221 | return usable.includes(current) ? current : usable[0]; |
| 222 | } |
| 223 | |
| 224 | function knownMember<T extends string>(values: T[], value: T, fallback: T): T { |
| 225 | return values.includes(value) ? value : fallback; |
| 226 | } |
| 227 | |
| 228 | //make sure settings are playable (e.g. might come from an older browser version with different codec support) |
| 229 | export function playableSettings<T extends Combination>(settings: T): T { |
| 230 | const known = { |
| 231 | ...settings, |
| 232 | streamingMode: knownMember(Object.values(StreamingMode), settings.streamingMode, DEFAULT_STREAMING_MODE), |
| 233 | container: knownMember(Object.values(MediaContainer), settings.container, MediaContainer.mp4), |
| 234 | audioCodec: knownMember(Object.values(AudioCodec), settings.audioCodec, AudioCodec.opus), |
| 235 | ...(settings.videoCodec === undefined |
| 236 | ? {} |
| 237 | : { videoCodec: knownMember(Object.values(VideoCodec), settings.videoCodec, VideoCodec.none) }), |
| 238 | }; |
| 239 | if (checkSupport(known).supported) return known; |
| 240 | |
| 241 | //outermost field first: the container decides which codecs are on offer, and the video codec decides |
| 242 | //which audio codecs are left over |
| 243 | const container = firstUsable(known.container, usableContainers(known)); |
| 244 | if (container === undefined) { |
| 245 | if (known.streamingMode === DEFAULT_STREAMING_MODE) return known; |
| 246 | return playableSettings({ ...known, streamingMode: DEFAULT_STREAMING_MODE }); |
| 247 | } |
| 248 | const withContainer = { ...known, container }; |
| 249 | |
| 250 | const videoCodec = |
| 251 | known.videoCodec === undefined ? undefined : firstUsable(known.videoCodec, usableVideoCodecs(withContainer)); |
| 252 | const withVideoCodec = videoCodec === undefined ? withContainer : { ...withContainer, videoCodec }; |
| 253 | |
| 254 | const audioCodec = firstUsable(withVideoCodec.audioCodec, usableAudioCodecs(withVideoCodec)); |
| 255 | //the container was only offered because some codec pair inside it works, so this cannot come up empty |
| 256 | if (audioCodec === undefined) return known; |
| 257 | return { ...withVideoCodec, audioCodec }; |
| 258 | } |
| 259 | |
| 260 | export async function initCapabilities(): Promise<void> { |
| 261 | //buffered and progressive have the same support ("file"), so only one check needed |
| 262 | for (const streamingMode of [StreamingMode.mse, StreamingMode.progressive]) { |
| 263 | for (const container of Object.values(MediaContainer)) { |
| 264 | for (const audioCodec of containerAudioCodecs(container)) { |
| 265 | for (const videoCodec of containerVideoCodecs(container)) { |
| 266 | //the bitrate picks the resolution ladder rung and with it the codec level, so each one the |
| 267 | //menu offers is its own probe. an audio-only combination has none to vary - and "none" is |
| 268 | //exactly what an absent videoCodec resolves to in checkSupport |
| 269 | const videoBitrates = videoCodec === VideoCodec.none ? [undefined] : VIDEO_BITRATES; |
| 270 | for (const videoBitrate of videoBitrates) { |
| 271 | checkSupport({ container, streamingMode, audioCodec, videoCodec, videoBitrate }); |
| 272 | } |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | } |
| 277 | //every probe started above resolves into the same map, and settling one cannot start another |
| 278 | await Promise.all(pending.values()); |
| 279 | } |
| 280 |