diff --git a/src/Connect.spec.ts b/src/Connect.spec.ts index fc78203..b30159a 100644 --- a/src/Connect.spec.ts +++ b/src/Connect.spec.ts @@ -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', () => { @@ -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' }]); + }); + }); }); diff --git a/src/Connect.ts b/src/Connect.ts index 2b76ec4..92f6c19 100644 --- a/src/Connect.ts +++ b/src/Connect.ts @@ -7,6 +7,134 @@ 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; + }; + +export type MediaKeyCertificate = + | string + | Uint8Array + | { + url: string; + headers?: Record; + }; + /** * Parameters of a key system for encrypted streams (DRM) * @@ -14,35 +142,27 @@ import { log } from './Log'; * * 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; + 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[]; }; /** @@ -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; + contentProtection?: Record; /** * 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 diff --git a/src/Util.spec.ts b/src/Util.spec.ts index 0365fcc..70850d3 100644 --- a/src/Util.spec.ts +++ b/src/Util.spec.ts @@ -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 😭'; diff --git a/src/Util.ts b/src/Util.ts index 2b925c1..29baf49 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -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. diff --git a/vitest.config.ts b/vitest.config.ts index 7eaacb7..686abfe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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 },