Skip to content
131 changes: 130 additions & 1 deletion src/Connect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { describe, it, expect } from 'vitest';
import { Type, Params, buildURL, defineMediaExt } from './Connect';
import { Type, Params, buildURL, createMediaKeySystemConfigurations, defineMediaExt } from './Connect';

describe('Connect', () => {
describe('defineMediaExt', () => {
Expand Down Expand Up @@ -197,4 +197,133 @@ describe('Connect', () => {
expect(() => buildURL(Type.HESP, params)).toThrow(Error);
});
});

describe('createMediaKeySystemConfigurations', () => {
it('should build the cartesian product of content types and robustness values', () => {
const configurations = createMediaKeySystemConfigurations({
audioContentTypes: ['audio/mp4; codecs="mp4a.40.2"'],
videoContentTypes: ['video/mp4; codecs="avc1.640028"', 'video/mp4; codecs="hvc1.1.6.L93.B0"'],
audioRobustness: ['SW_SECURE_CRYPTO', 'HW_SECURE_CRYPTO'],
videoRobustness: ['SW_SECURE_DECODE', 'HW_SECURE_DECODE']
});

expect(configurations).toHaveLength(8);
expect(configurations[0]).toEqual({
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'SW_SECURE_CRYPTO' }],
videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.640028"', robustness: 'SW_SECURE_DECODE' }]
});
expect(configurations[7]).toEqual({
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'HW_SECURE_CRYPTO' }],
videoCapabilities: [
{ contentType: 'video/mp4; codecs="hvc1.1.6.L93.B0"', robustness: 'HW_SECURE_DECODE' }
]
});
});

it('should omit empty robustness values', () => {
const configurations = createMediaKeySystemConfigurations({
audioContentTypes: 'audio/mp4; codecs="mp4a.40.2"',
videoContentTypes: 'video/mp4; codecs="avc1.640028"'
});

expect(configurations).toEqual([
{
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }],
videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.640028"' }]
}
]);
});

it('should preserve unresolved audio capabilities without duplicating video-only configurations', () => {
const configurations = createMediaKeySystemConfigurations({
videoContentTypes: 'video/mp4; codecs="avc1.640028"',
audioRobustness: ['SW_SECURE_CRYPTO', 'HW_SECURE_CRYPTO'],
videoRobustness: ['SW_SECURE_DECODE', 'HW_SECURE_DECODE']
});

expect(configurations).toEqual([
{
audioCapabilities: [{ robustness: 'SW_SECURE_CRYPTO' }, { robustness: 'HW_SECURE_CRYPTO' }],
videoCapabilities: [
{ contentType: 'video/mp4; codecs="avc1.640028"', robustness: 'SW_SECURE_DECODE' }
]
},
{
audioCapabilities: [{ robustness: 'SW_SECURE_CRYPTO' }, { robustness: 'HW_SECURE_CRYPTO' }],
videoCapabilities: [
{ contentType: 'video/mp4; codecs="avc1.640028"', robustness: 'HW_SECURE_DECODE' }
]
}
]);
});

it('should preserve unresolved video capabilities without duplicating audio-only configurations', () => {
const configurations = createMediaKeySystemConfigurations({
audioContentTypes: 'audio/mp4; codecs="mp4a.40.2"',
audioRobustness: ['SW_SECURE_CRYPTO', 'HW_SECURE_CRYPTO'],
videoRobustness: ['SW_SECURE_DECODE', 'HW_SECURE_DECODE']
});

expect(configurations).toEqual([
{
audioCapabilities: [
{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'SW_SECURE_CRYPTO' }
],
videoCapabilities: [{ robustness: 'SW_SECURE_DECODE' }, { robustness: 'HW_SECURE_DECODE' }]
},
{
audioCapabilities: [
{ contentType: 'audio/mp4; codecs="mp4a.40.2"', robustness: 'HW_SECURE_CRYPTO' }
],
videoCapabilities: [{ robustness: 'SW_SECURE_DECODE' }, { robustness: 'HW_SECURE_DECODE' }]
}
]);
});

it('should create unresolved capability templates from robustness-only inputs', () => {
const configurations = createMediaKeySystemConfigurations({
audioRobustness: ['A1', 'A2'],
videoRobustness: ['V1', 'V2']
});

expect(configurations).toEqual([
{
audioCapabilities: [{ robustness: 'A1' }, { robustness: 'A2' }],
videoCapabilities: [{ robustness: 'V1' }, { robustness: 'V2' }]
}
]);
});

it('should keep the base configuration on each generated entry', () => {
const configurations = createMediaKeySystemConfigurations({
audioContentTypes: 'audio/mp4; codecs="mp4a.40.2"',
baseConfiguration: {
initDataTypes: ['cenc'],
distinctiveIdentifier: 'optional',
persistentState: 'optional',
sessionTypes: ['temporary']
}
});

expect(configurations).toEqual([
{
initDataTypes: ['cenc'],
distinctiveIdentifier: 'optional',
persistentState: 'optional',
sessionTypes: ['temporary'],
audioCapabilities: [{ contentType: 'audio/mp4; codecs="mp4a.40.2"' }]
}
]);
});

it('should return the base configuration when no template inputs are provided', () => {
const configurations = createMediaKeySystemConfigurations({
baseConfiguration: {
persistentState: 'optional'
}
});

expect(configurations).toEqual([{ persistentState: 'optional' }]);
});
});
});
160 changes: 140 additions & 20 deletions src/Connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,162 @@ import * as Util from './Util';
import { NetAddress } from './NetAddress';
import { log } from './Log';

/**
* Parameters of the createMediaKeySystemConfigurations helper function
*
* These parameters allow to write concise configurations for encrypted streams by specifying
* common audio/video content types, robustness values, and a base configuration.
*/
export type MediaKeySystemConfigurationParams = {
audioContentTypes?: string | string[];
videoContentTypes?: string | string[];
audioRobustness?: string | string[];
videoRobustness?: string | string[];
baseConfiguration?: MediaKeySystemConfiguration;
};

/**
* Helper to create the MediaKeySystem.configurations from :
* - common audio/video content types
* - base configuration (with other parameters)
* - robustness values
* It generates all combinations of audio and video content types and robustness values, and merges them with
* the base configuration if provided.
*
* The returned configurations can be assigned to {@link MediaKeySystem.configurations}. When the caller
* omits capability content types, a DRM implementation may enrich those templates with stream metadata before
* passing the final configurations to `requestMediaKeySystemAccess()`.
*
* Example of usage :
*
* ```ts
* const keySystem: MediaKeySystem = {
* license: 'https://license-server.com/getLicense',
* configurations: createMediaKeySystemConfigurations({
* audioContentTypes: ['audio/mp4; codecs="mp4a.40.2"'],
* videoContentTypes: ['video/mp4; codecs="avc1.640028"', 'video/mp4; codecs="hvc1.1.6.L93.B0"'],
* audioRobustness: ['SW_SECURE_CRYPTO', 'HW_SECURE_CRYPTO'],
* videoRobustness: ['SW_SECURE_DECODE', 'HW_SECURE_DECODE']
* })
* };
* ```
*
* @returns An array of MediaKeySystemConfiguration templates.
*/
export function createMediaKeySystemConfigurations(
params: MediaKeySystemConfigurationParams
): MediaKeySystemConfiguration[] {
const audioContentTypes = Util.toStringArray(params.audioContentTypes);
const videoContentTypes = Util.toStringArray(params.videoContentTypes);
const requestedAudioRobustness = Util.toStringArray(params.audioRobustness);
const requestedVideoRobustness = Util.toStringArray(params.videoRobustness);

if (!audioContentTypes.length && !videoContentTypes.length) {
if (!requestedAudioRobustness.length && !requestedVideoRobustness.length) {
return params.baseConfiguration ? [{ ...params.baseConfiguration }] : [{}];
}
// If no content types are provided, we create a single configuration with robustness values only
return [
{
...params.baseConfiguration,
...(requestedAudioRobustness.length > 0 && {
audioCapabilities: requestedAudioRobustness.map(robustness => ({ ...{ robustness } }))
}),
...(requestedVideoRobustness.length > 0 && {
videoCapabilities: requestedVideoRobustness.map(robustness => ({ ...{ robustness } }))
})
}
];
}

const configurations: MediaKeySystemConfiguration[] = [];

const audioRobustness = audioContentTypes.length ? Util.toStringArray(params.audioRobustness, ['']) : [''];
const videoRobustness = videoContentTypes.length ? Util.toStringArray(params.videoRobustness, ['']) : [''];
const unresolvedAudioCapabilities =
requestedAudioRobustness.length > 0
? requestedAudioRobustness.map(robustness => ({
...(robustness && { robustness })
}))
: undefined;
const unresolvedVideoCapabilities =
requestedVideoRobustness.length > 0
? requestedVideoRobustness.map(robustness => ({
...(robustness && { robustness })
}))
: undefined;
const audioInputs = audioContentTypes.length ? audioContentTypes : [''];
const videoInputs = videoContentTypes.length ? videoContentTypes : [''];

// Create a complete configuration for each combination of audio/video content types and robustness values
for (const audioContentType of audioInputs) {
for (const videoContentType of videoInputs) {
for (const audioR of audioRobustness) {
for (const videoR of videoRobustness) {
const audioCapabilities = audioContentType
? [{ contentType: audioContentType, ...(audioR && { robustness: audioR }) }]
: unresolvedAudioCapabilities;
const videoCapabilities = videoContentType
? [{ contentType: videoContentType, ...(videoR && { robustness: videoR }) }]
: unresolvedVideoCapabilities;

const config: MediaKeySystemConfiguration = {
...params.baseConfiguration,
...(audioCapabilities && { audioCapabilities }),
...(videoCapabilities && { videoCapabilities })
};
configurations.push(config);
}
}
}
}

return configurations;
}

export type MediaKeyLicense =
| string
| {
url: string;
headers?: Record<string, string>;
};

export type MediaKeyCertificate =
| string
| Uint8Array
| {
url: string;
headers?: Record<string, string>;
};

/**
* Parameters of a key system for encrypted streams (DRM)
*
* If the key system is a string, it's the URL of the license server.
*
* If the key system is an object, it's a key system configuration with more parameters.
*/
export type KeySystem =
export type MediaKeySystem =
| string
| {
/**
* The license server URL
*/
licenseUrl: string;
/**
* The certificate URL if needed (for FairPlay)
*
* Or directly the certificate
* The license URL or configuration for the key system. If it's a string, it's the URL of the license server.
*/
certificate?: string | Uint8Array;
license?: MediaKeyLicense;
/**
* The additional HTTP headers to send to the license server
* The certificate URL if needed (for FairPlay) or the certificate data as Uint8Array.
*/
headers?: Record<string, string>;
certificate?: MediaKeyCertificate;
/**
* Audio robustness level
* Optional MediaKeySystemConfiguration[].
*
* A list of robustness levels, prioritized by the order of the array.
*/
audioRobustness?: string[];
/**
* Video robustness level
* If metadata is available, configuration may enrich capabilities that do not define `contentType`
* before calling `requestMediaKeySystemAccess()`. Explicit `contentType` values provided by the user should
* take precedence over metadata-derived values.
*
* A list of robustness levels, prioritized by the order of the array.
* If metadata is not available, these configurations are expected to be complete enough to be used as-is.
*/
videoRobustness?: string[];
configurations?: MediaKeySystemConfiguration[];
};

/**
Expand Down Expand Up @@ -72,7 +192,7 @@ export type Params = {
* Map of keys to content protection settings for encrypted streams
* The key can be "com.apple.fps" for example for FairPlay
*/
contentProtection?: Record<string, KeySystem>;
contentProtection?: Record<string, MediaKeySystem>;
/**
* Optional media extension (mp4, flv, ts, rts), usefull for protocol like WebRTS which supports different container type.
* When not set, it's also an output parameter for {@link defineMediaExt} to indicate what is the media type selected
Expand Down
22 changes: 22 additions & 0 deletions src/Util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ describe('Util', () => {
});
});

describe('toStringArray', () => {
it('should normalize a string into an array', () => {
expect(Util.toStringArray('value')).toEqual(['value']);
});

it('should keep non-empty values from an array', () => {
expect(Util.toStringArray(['value1', '', 'value2'])).toEqual(['value1', 'value2']);
});

it('should return an empty array by default when no values are provided', () => {
expect(Util.toStringArray(undefined)).toEqual([]);
expect(Util.toStringArray('')).toEqual([]);
expect(Util.toStringArray([''])).toEqual([]);
});

it('should use the fallback array when provided', () => {
expect(Util.toStringArray(undefined, ['fallback'])).toEqual(['fallback']);
expect(Util.toStringArray('', ['fallback'])).toEqual(['fallback']);
expect(Util.toStringArray([''], ['fall', 'back'])).toEqual(['fall', 'back']);
});
});

describe('toBin', () => {
it('should convert string to UTF-8 representation in Uint8Array', () => {
const str = 'Hello 😭';
Expand Down
17 changes: 17 additions & 0 deletions src/Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ const _perf = performance; // to increase x10 now performance!
*/
export const EMPTY_FUNCTION = () => {};

/**
* Converts a string or array of strings into a normalized string array.
* Empty string values are filtered out. When no non-empty value remains,
* `defaultValue` is returned (defaults to `[]`).
*
* @param value The string or array of strings to normalize.
* @param defaultValue Returned when `value` is empty or contains only empty strings.
* @returns A normalized array of strings.
*/
export function toStringArray(value: string | string[] | undefined, defaultValue: string[] = []): string[] {
if (!value) {
return defaultValue;
}
const values = (Array.isArray(value) ? value : [value]).filter(Boolean);
return values.length ? values : defaultValue;
}

/**
* Returns an efficient timestamp in milliseconds elapsed since performance.timeOrigin,
* representing the start of the current JavaScript execution context.
Expand Down
3 changes: 2 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
coverage: {
include: ['src/**/*.ts'],
provider: 'istanbul',
reporter: ['lcov'],
reporter: ['text', 'lcov'],
reportsDirectory: './coverage',
reportOnFailure: true
},
Expand Down
Loading