capabilities.ts
⎇
Raw
1import { AudioCodec, MediaContainer, VideoCodec } from "music-server-shared/types";
2import {
3 audioCodecToken,
4 containerMimeType,
5 containerNamesCodecs,
6 createRFC6381String,
7 ladderMaxWidth,
8 type VideoStreamInfo,
9 videoCodecToken,
10} from "music-server-shared/utils";
11import { StreamingMode, VIDEO_BITRATES } from "./types";
12
13export 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
21export interface Combination {
22 container: MediaContainer;
23 streamingMode: StreamingMode;
24 audioCodec: AudioCodec;
25 videoCodec?: VideoCodec;
26 videoBitrate?: number;
27}
28
29export const hasMediaSource = "MediaSource" in window;
30export 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
35const 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
39const 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
46const 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
53export 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
58export 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
64const NOMINAL_FRAMERATE = 30;
65const NOMINAL_UNSCALED_WIDTH = 3840;
66
67function 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
78let probeElement: HTMLVideoElement | undefined;
79
80function 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
88const decodingCache = new Map<string, { supported: boolean; smooth: boolean }>();
89const 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
93function 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
111export 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
175function 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
189export function checkContainerSupport(combination: Combination): Support {
190 return checkContainer(
191 combination,
192 combination.videoCodec === undefined ? [VideoCodec.none] : containerVideoCodecs(combination.container),
193 );
194}
195
196function usableAudioCodecs(combination: Combination): AudioCodec[] {
197 return containerAudioCodecs(combination.container).filter(
198 (audioCodec) => checkSupport({ ...combination, audioCodec }).supported,
199 );
200}
201
202function usableVideoCodecs(combination: Combination): VideoCodec[] {
203 return containerVideoCodecs(combination.container).filter(
204 (videoCodec) => checkContainer(combination, [videoCodec]).supported,
205 );
206}
207
208function 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
216export function selectableVideoCodecs(combination: Combination): VideoCodec[] {
217 return usableVideoCodecs(combination).filter((videoCodec) => videoCodec !== VideoCodec.none);
218}
219
220function firstUsable<T>(current: T, usable: T[]): T | undefined {
221 return usable.includes(current) ? current : usable[0];
222}
223
224function 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)
229export 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
260export 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