initCapabilities refactoring
Mwebclient/src/capabilities.ts
@@ -85,62 +85,93 @@ function progressivelyPlayable(mimeType: string): boolean {
//MediaSource.isTypeSupported is the cheap gate but a lenient one: it parses the type and checks the
//codecs in isolation, so it says yes to combinations the decoder then refuses. decodingInfo asks the
//actual decoder pipeline, per track, with the resolution and framerate attached
const decodingCache = new Map<string, { supported: boolean; smooth: boolean }>();
const pending = new Map<string, Promise<void>>();
interface DecodingVerdict {
supported: boolean;
smooth: boolean;
}
//the final verdict for every combination initCapabilities enumerates - the only thing checkSupport reads
const supportCache = new Map<string, Support>();
const pending = new Map<string, Promise<DecodingVerdict>>();
//buffered and progressive have the same support: both are checked with canPlayType and probed as decoding
//type "file", so the key collapses them and initCapabilities only has to enumerate one of them. the
//bitrate is keyed only when there is a video track, so an audio-only verdict is not stored once per
//resolution ladder rung for an answer that cannot depend on it
function cacheKey(combination: Combination): string {
const { container, streamingMode, audioCodec, videoBitrate } = combination;
const videoCodec = combination.videoCodec ?? VideoCodec.none;
const mode = streamingMode === StreamingMode.mse ? "mse" : "file";
return `${mode}|${container}|${audioCodec}|${videoCodec}|${videoCodec === VideoCodec.none ? "" : videoBitrate}`;
}
//the same key really does come up twice: progressive and buffered are both type "file", so initCapabilities
//enumerates every one of these configurations a second time
function startDecodingProbe(key: string, configuration: MediaDecodingConfiguration): void {
if (pending.has(key)) return;
function startDecodingProbe(key: string, configuration: MediaDecodingConfiguration): Promise<DecodingVerdict> {
const probe = navigator.mediaCapabilities
.decodingInfo(configuration)
.then((info) => {
decodingCache.set(key, { supported: info.supported, smooth: info.smooth });
})
.then((info) => ({ supported: info.supported, smooth: info.smooth }))
.catch(() => {
//a configuration this browser cannot even parse tells us nothing beyond what the sync checks
//already established, so record the permissive answer rather than disabling a working option
decodingCache.set(key, { supported: true, smooth: true });
return { supported: true, smooth: true };
})
.finally(() => {
pending.delete(key);
});
pending.set(key, probe);
return probe;
}
export function checkSupport(combination: Combination): Support {
function decodingSupport(verdict: DecodingVerdict, mimeType: string): Support {
if (!verdict.supported) return { supported: false, reason: `the browser's decoders reject ${mimeType}` };
if (!verdict.smooth)
return { supported: true, reason: "supported, but the browser does not expect to decode this smoothly" };
return { supported: true };
}
function probeSupport(combination: Combination): void {
const { container, streamingMode, audioCodec } = combination;
const videoCodec = combination.videoCodec ?? VideoCodec.none;
const hasVideo = videoCodec !== VideoCodec.none;
const key = cacheKey(combination);
if (streamingMode === StreamingMode.mse) {
if (!hasMediaSource) return { supported: false, reason: "this browser has no MediaSource support" };
if (!MSE_BYTE_STREAM_FORMATS.includes(container))
return {
if (!hasMediaSource) {
supportCache.set(key, { supported: false, reason: "this browser has no MediaSource support" });
return;
}
if (!MSE_BYTE_STREAM_FORMATS.includes(container)) {
supportCache.set(key, {
supported: false,
reason: `${container} has no registered MSE byte stream format, so no browser can stream it - pick progressive or buffered`,
};
});
return;
}
//measured against -live 1, -dash 1, -default_mode infer, explicit dispositions, forced cluster
//limits and both track orders: the append succeeds, updateend fires, and buffered stays empty
if (container === MediaContainer.webm && hasVideo)
return {
supported: false,
reason: "effectively unsupported by browsers in mse",
};
if (container === MediaContainer.webm && hasVideo) {
supportCache.set(key, { supported: false, reason: "effectively unsupported by browsers in mse" });
return;
}
}
const video = hasVideo ? nominalVideoStream(combination.videoBitrate ?? 0) : undefined;
const mimeType = createRFC6381String(container, videoCodec, audioCodec, video);
if (streamingMode === StreamingMode.mse) {
if (!MediaSource.isTypeSupported(mimeType))
return { supported: false, reason: `the browser rejects ${mimeType} in a SourceBuffer` };
if (!MediaSource.isTypeSupported(mimeType)) {
supportCache.set(key, { supported: false, reason: `the browser rejects ${mimeType} in a SourceBuffer` });
return;
}
} else if (!progressivelyPlayable(mimeType)) {
return { supported: false, reason: `the browser cannot play ${mimeType}` };
supportCache.set(key, { supported: false, reason: `the browser cannot play ${mimeType}` });
return;
}
//decodingInfo needs one content type per track, which is why the tokens are built here rather than
//reusing the combined string above. a container that names no codec has nothing to ask about
if (!containerNamesCodecs(container)) return { supported: true };
if (!containerNamesCodecs(container)) {
supportCache.set(key, { supported: true });
return;
}
const configuration: MediaDecodingConfiguration = {
type: streamingMode === StreamingMode.mse ? "media-source" : "file",
audio: { contentType: `${containerMimeType(container, false)}; codecs="${audioCodecToken(audioCodec)}"` },
@@ -157,18 +188,18 @@ export function checkSupport(combination: Combination): Support {
: {}),
};
//keyed on the bitrate only when there is a video track, so an audio-only probe is not re-run once per
//resolution ladder rung for an answer that cannot depend on it
const key = `${configuration.type}|${configuration.audio?.contentType}|${configuration.video?.contentType}|${video?.bitrate}`;
const cached = decodingCache.get(key);
if (!cached) {
startDecodingProbe(key, configuration);
return { supported: true };
const decodingKey = `${configuration.type}|${configuration.audio?.contentType}|${configuration.video?.contentType}|${video?.bitrate}`;
const probe = pending.get(decodingKey) ?? startDecodingProbe(decodingKey, configuration);
void probe.then((verdict) => supportCache.set(key, decodingSupport(verdict, mimeType)));
}
export function checkSupport(combination: Combination): Support {
const support = supportCache.get(cacheKey(combination));
if (support === undefined) {
console.error("support queried for a combination that was never probed", combination);
return { supported: false, reason: "this combination was never probed - a bug, see the console" };
}
if (!cached.supported) return { supported: false, reason: `the browser's decoders reject ${mimeType}` };
if (!cached.smooth)
return { supported: true, reason: "supported, but the browser does not expect to decode this smoothly" };
return { supported: true };
return support;
}
//a container is offerable when at least one codec combination inside it works in the current mode
@@ -221,7 +252,7 @@ function firstUsable<T>(current: T, usable: T[]): T | undefined {
return usable.includes(current) ? current : usable[0];
}
function knownMember<T extends string>(values: T[], value: T, fallback: T): T {
function knownMember<T>(values: T[], value: T, fallback: T): T {
return values.includes(value) ? value : fallback;
}
@@ -235,6 +266,9 @@ export function playableSettings<T extends Combination>(settings: T): T {
...(settings.videoCodec === undefined
? {}
: { videoCodec: knownMember(Object.values(VideoCodec), settings.videoCodec, VideoCodec.none) }),
...(settings.videoBitrate === undefined
? {}
: { videoBitrate: knownMember(VIDEO_BITRATES, settings.videoBitrate, 8_000_000) }),
};
if (checkSupport(known).supported) return known;
@@ -258,22 +292,21 @@ export function playableSettings<T extends Combination>(settings: T): T {
}
export async function initCapabilities(): Promise<void> {
//buffered and progressive have the same support ("file"), so only one check needed
//buffered and progressive have the same support ("file"), so only one of them is probed
for (const streamingMode of [StreamingMode.mse, StreamingMode.progressive]) {
for (const container of Object.values(MediaContainer)) {
for (const audioCodec of containerAudioCodecs(container)) {
for (const videoCodec of containerVideoCodecs(container)) {
//the bitrate picks the resolution ladder rung and with it the codec level, so each one the
//menu offers is its own probe. an audio-only combination has none to vary - and "none" is
//exactly what an absent videoCodec resolves to in checkSupport
//exactly what an absent videoCodec resolves to in probeSupport
const videoBitrates = videoCodec === VideoCodec.none ? [undefined] : VIDEO_BITRATES;
for (const videoBitrate of videoBitrates) {
checkSupport({ container, streamingMode, audioCodec, videoCodec, videoBitrate });
probeSupport({ container, streamingMode, audioCodec, videoCodec, videoBitrate });
}
}
}
}
}
//every probe started above resolves into the same map, and settling one cannot start another
await Promise.all(pending.values());
}