Skip to content

Commit

Permalink
fix(cast): Use cast platform APIs in MediaCapabilties polyfill (shaka…
Browse files Browse the repository at this point in the history
…-project#4727)

See shaka-project#4726 for more
context.

This allows Cast devices to properly filter stream variants with a
resolution surpassing that of the device's capabilities.

We place the fix in the `MediaCapabilities` polyfill since it's intended
to be the right way to check for anything related to platform support.

HDR support checks will require `eotf=smpte2048`, as indicated in
shaka-project#2813 (comment).
Specifically, a `{hev|hvc}1.2` profile is only an *indication* of an HDR
transfer function, but *may* be a non-HDR 10-bit color stream.

In Cast, the platform can distinguish between the two by explicitly
providing the transfer function; it uses `smpte2048` (`"PQ"`) because
this is the "basis of HDR video formats..."
(https://en.wikipedia.org/wiki/Perceptual_quantizer).
  • Loading branch information
JulianDomingo committed Dec 1, 2022
1 parent 795342d commit 5d6f56a
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 9 deletions.
68 changes: 59 additions & 9 deletions lib/polyfill/media_capabilities.js
Expand Up @@ -8,6 +8,7 @@ goog.provide('shaka.polyfill.MediaCapabilities');

goog.require('shaka.log');
goog.require('shaka.polyfill');
goog.require('shaka.util.Error');
goog.require('shaka.util.Platform');


Expand Down Expand Up @@ -96,37 +97,48 @@ shaka.polyfill.MediaCapabilities = class {
return res;
}

const videoConfig = mediaDecodingConfig['video'];
const audioConfig = mediaDecodingConfig['audio'];

if (mediaDecodingConfig.type == 'media-source') {
if (!shaka.util.Platform.supportsMediaSource()) {
return res;
}
// Use 'MediaSource.isTypeSupported' to check if the stream is supported.
if (mediaDecodingConfig['video']) {
const contentType = mediaDecodingConfig['video'].contentType;
const isSupported = MediaSource.isTypeSupported(contentType);
// Cast platforms will additionally check canDisplayType(), which
// accepts extended MIME type parameters.
// See: https://github.com/shaka-project/shaka-player/issues/4726
if (videoConfig) {
let isSupported;
if (shaka.util.Platform.isChromecast()) {
isSupported =
shaka.polyfill.MediaCapabilities.canCastDisplayType_(videoConfig);
} else {
isSupported = MediaSource.isTypeSupported(videoConfig.contentType);
}
if (!isSupported) {
return res;
}
}

if (mediaDecodingConfig['audio']) {
const contentType = mediaDecodingConfig['audio'].contentType;
if (audioConfig) {
const contentType = audioConfig.contentType;
const isSupported = MediaSource.isTypeSupported(contentType);
if (!isSupported) {
return res;
}
}
} else if (mediaDecodingConfig.type == 'file') {
if (mediaDecodingConfig['video']) {
const contentType = mediaDecodingConfig['video'].contentType;
if (videoConfig) {
const contentType = videoConfig.contentType;
const isSupported = shaka.util.Platform.supportsMediaType(contentType);
if (!isSupported) {
return res;
}
}

if (mediaDecodingConfig['audio']) {
const contentType = mediaDecodingConfig['audio'].contentType;
if (audioConfig) {
const contentType = audioConfig.contentType;
const isSupported = shaka.util.Platform.supportsMediaType(contentType);
if (!isSupported) {
return res;
Expand Down Expand Up @@ -217,6 +229,44 @@ shaka.polyfill.MediaCapabilities = class {
return res;
}

/**
* Checks if the given media parameters of the video or audio streams are
* supported by the Cast platform.
* @param {!VideoConfiguration} videoConfig The 'video' field of the
* MediaDecodingConfiguration.
* @return {boolean} `true` when the stream can be displayed on a Cast device.
* @private
*/
static canCastDisplayType_(videoConfig) {
if (!(window.cast)) {
shaka.log.error('Expected cast namespace to be available!');
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.CAST_API_UNAVAILABLE);
} else if (!(cast.__platform__ && cast.__platform__.canDisplayType)) {
shaka.log.warning('Expected cast APIs to be available! Falling back to ' +
'MediaSource.isTypeSupported() for type support.');
return MediaSource.isTypeSupported(videoConfig.contentType);
}

let displayType = videoConfig.contentType;
if (videoConfig.width && videoConfig.height) {
displayType +=
`; width=${videoConfig.width}; height=${videoConfig.height}`;
}
if (videoConfig.framerate) {
displayType += `; framerate=${videoConfig.framerate}`;
}
if (videoConfig.transferFunction === 'pq') {
// A "PQ" transfer function indicates this is an HDR-capable stream;
// "smpte2084" is the published standard. We need to inform the platform
// this query is specifically for HDR.
displayType += '; eotf=smpte2084';
}
return cast.__platform__.canDisplayType(displayType);
}

/**
* A method for generating a key for the MediaKeySystemAccessRequests cache.
*
Expand Down
103 changes: 103 additions & 0 deletions test/polyfill/media_capabilities_unit.js
Expand Up @@ -5,13 +5,18 @@
*/

describe('MediaCapabilities', () => {
const Util = shaka.test.Util;
const originalCast = window['cast'];
const originalVendor = navigator.vendor;
const originalUserAgent = navigator.userAgent;
const originalRequestMediaKeySystemAccess =
navigator.requestMediaKeySystemAccess;
const originalMediaCapabilities = navigator.mediaCapabilities;

/** @type {MediaDecodingConfiguration} */
let mockDecodingConfig;
/** @type {!jasmine.Spy} */
let mockCanDisplayType;

beforeAll(() => {
Object.defineProperty(window['navigator'],
Expand Down Expand Up @@ -64,9 +69,17 @@ describe('MediaCapabilities', () => {
},
};
shaka.polyfill.MediaCapabilities.memoizedMediaKeySystemAccessRequests_ = {};

mockCanDisplayType = jasmine.createSpy('canDisplayType');
mockCanDisplayType.and.returnValue(false);
});

afterEach(() => {
window['cast'] = originalCast;
});

afterAll(() => {
window['cast'] = originalCast;
Object.defineProperty(window['navigator'],
'userAgent', {value: originalUserAgent});
Object.defineProperty(window['navigator'],
Expand Down Expand Up @@ -172,5 +185,95 @@ describe('MediaCapabilities', () => {
expect(requestKeySystemAccessSpy)
.toHaveBeenCalledTimes(1);
});

it('throws when the cast namespace is not available', async () => {
// Temporarily remove window.cast to trigger error. It's restored after
// every test.
delete window['cast'];

const isChromecastSpy =
spyOn(shaka['util']['Platform'],
'isChromecast').and.returnValue(true);
const expected = Util.jasmineError(new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.CAST,
shaka.util.Error.Code.CAST_API_UNAVAILABLE));
const isTypeSupportedSpy =
spyOn(window['MediaSource'], 'isTypeSupported').and.returnValue(true);

shaka.polyfill.MediaCapabilities.install();
await expectAsync(
navigator.mediaCapabilities.decodingInfo(mockDecodingConfig))
.toBeRejectedWith(expected);

expect(isTypeSupportedSpy).not.toHaveBeenCalled();
// 1 (during install()) + 1 (for video config check).
expect(isChromecastSpy).toHaveBeenCalledTimes(2);
});

it('falls back to isTypeSupported() when canDisplayType() missing',
async () => {
// We only set the cast namespace, but not the canDisplayType() API.
window['cast'] = {};
const isChromecastSpy =
spyOn(shaka['util']['Platform'],
'isChromecast').and.returnValue(true);
const isTypeSupportedSpy =
spyOn(window['MediaSource'], 'isTypeSupported')
.and.returnValue(true);

shaka.polyfill.MediaCapabilities.install();
await navigator.mediaCapabilities.decodingInfo(mockDecodingConfig);

expect(mockCanDisplayType).not.toHaveBeenCalled();
// 1 (during install()) + 1 (for video config check).
expect(isChromecastSpy).toHaveBeenCalledTimes(2);
// 1 (fallback in canCastDisplayType()) +
// 1 (mockDecodingConfig.audio).
expect(isTypeSupportedSpy).toHaveBeenCalledTimes(2);
});

it('should use cast.__platform__.canDisplayType for "supported" field ' +
'when platform is Cast', async () => {
// We're using quotes to access window.cast because the compiler
// knows about lots of Cast-specific APIs we aren't mocking. We
// don't need this mock strictly type-checked.
window['cast'] = {
__platform__: {canDisplayType: mockCanDisplayType},
};
const isChromecastSpy =
spyOn(shaka['util']['Platform'],
'isChromecast').and.returnValue(true);
const isTypeSupportedSpy =
spyOn(window['MediaSource'], 'isTypeSupported').and.returnValue(true);

// Tests an HDR stream's extended MIME type is correctly provided.
mockDecodingConfig.video.transferFunction = 'pq';
mockDecodingConfig.video.contentType =
'video/mp4; codecs="hev1.2.4.L153.B0"';
// Round to a whole number since we can't rely on number => string
// conversion precision on all devices.
mockDecodingConfig.video.framerate = 24;
mockCanDisplayType.and.callFake((type) => {
expect(type).toBe(
'video/mp4; ' +
'codecs="hev1.2.4.L153.B0"; ' +
'width=512; ' +
'height=288; ' +
'framerate=24; ' +
'eotf=smpte2084');
return true;
});

shaka.polyfill.MediaCapabilities.install();
await navigator.mediaCapabilities.decodingInfo(mockDecodingConfig);

// 1 (during install()) + 1 (for video config check).
expect(isChromecastSpy).toHaveBeenCalledTimes(2);
// 1 (mockDecodingConfig.audio).
expect(isTypeSupportedSpy).toHaveBeenCalledTimes(1);
// Called once in canCastDisplayType.
expect(mockCanDisplayType).toHaveBeenCalledTimes(1);
});
});
});

0 comments on commit 5d6f56a

Please sign in to comment.