diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index c88337b5f27..d0f0c28c335 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -1195,6 +1195,7 @@ export type EMEControllerConfig = { drmSystemOptions: DRMSystemOptions | undefined; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; requireKeySystemAccessOnStart: boolean; + requiresEncryptionInfoInAllInitSegments: boolean; }; // Warning: (ae-missing-release-tag) "ErrorActionFlags" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/config.ts b/src/config.ts index 7ba0901fb2f..2a4bac35248 100644 --- a/src/config.ts +++ b/src/config.ts @@ -123,6 +123,7 @@ export type EMEControllerConfig = { drmSystemOptions: DRMSystemOptions | undefined; requestMediaKeySystemAccessFunc: MediaKeyFunc | null; requireKeySystemAccessOnStart: boolean; + requiresEncryptionInfoInAllInitSegments: boolean; }; export interface FragmentLoaderConstructor { @@ -441,6 +442,7 @@ export const hlsDefaultConfig: HlsConfig = { ? requestMediaKeySystemAccess : null, // used by eme-controller requireKeySystemAccessOnStart: false, // used by eme-controller + requiresEncryptionInfoInAllInitSegments: false, // used by buffer-controller testBandwidth: true, progressive: false, lowLatencyMode: true, diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index 388e0dc33ed..a7bf11e0342 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -17,6 +17,7 @@ import { isCompatibleTrackChange, isManagedMediaSource, } from '../utils/mediasource-helper'; +import { fakeEncryption } from '../utils/mp4-tools'; import { stringify } from '../utils/safe-json-stringify'; import type { FragmentTracker } from './fragment-tracker'; import type { HlsConfig } from '../config'; @@ -845,7 +846,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe this.updateTimestampOffset(sb, offset, 0.000001, type, sn, cc); } } - this.appendExecutor(data, type); + this.appendExecutor(data, type, sn === 'initSegment'); }, onStart: () => { // logger.debug(`[buffer-controller]: ${type} SourceBuffer updatestart`); @@ -1671,6 +1672,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe private appendExecutor( data: Uint8Array, type: SourceBufferName, + isInitSegment?: boolean, ) { const track = this.tracks[type]; const sb = track?.buffer; @@ -1681,6 +1683,14 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } track.ending = false; track.ended = false; + + if ( + isInitSegment && + this.hls.config.requiresEncryptionInfoInAllInitSegments + ) { + data = fakeEncryption(data) as Uint8Array; + } + sb.appendBuffer(data); } diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index be093141986..3969080abce 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -1446,3 +1446,217 @@ function parsePssh(view: DataView): PsshData | PsshInvalidResult { size, }; } + +function createFakeSinfBox( + encType: Uint8Array, + fourCC: string, + entry: Uint8Array, +) { + const entryCopy = new Uint8Array(entry); + entryCopy.set(encType, 4); + + const sinf = mp4Box( + [0x73, 0x69, 0x6e, 0x66], // 'sinf' + mp4Box( + [0x66, 0x72, 0x6d, 0x61], // 'frma' + new Uint8Array(fourCC.split('').map((c) => c.charCodeAt(0))), + ), + mp4Box( + [0x73, 0x63, 0x68, 0x6d], // 'schm' + new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, 0x63, 0x65, 0x6e, 0x63, 0x00, 0x01, 0x00, 0x00, + ]), + ), + mp4Box( + [0x73, 0x63, 0x68, 0x69], // 'schi' + mp4Box( + [0x74, 0x65, 0x6e, 0x63], // 'tenc' + new Uint8Array([ + 0x00, // version 0 + 0x00, + 0x00, + 0x00, // flags + 0x00, + 0x00, // Reserved fields + 0x01, // Default protected: true + 0x08, // Default per-sample IV size: 8 + 0x00, // Default KID + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ]), + ), + ), + ); + const entryWithSinf = appendUint8Array(entryCopy, sinf); + writeUint32(entryWithSinf, 0, entryWithSinf.length); + return entryWithSinf; +} + +export function fakeEncryption(initSegment: Uint8Array) { + const initSegmentCopy = new Uint8Array(initSegment); + + const moov = findBox(initSegmentCopy, ['moov'])[0]; + if (!moov) { + return initSegment; + } + + // Only patch single trak files (no audio+video) + const traks = findBox(moov, ['trak']); + if (!traks || traks.length > 1) { + return initSegment; + } + + const trak = traks[0]; + const mdia = findBox(trak, ['mdia'])[0]; + const minf = findBox(mdia, ['minf'])[0]; + const stbl = findBox(minf, ['stbl'])[0]; + const stsdBox = findBox(stbl, ['stsd'])[0]; + if (!mdia || !minf || !stbl || !stsdBox) { + return initSegment; + } + + // Patch stsd box + const entryCount = readUint32(stsdBox, 4); + let entryOffset = 8; + const newEntries: Uint8Array[] = []; + for (let i = 0; i < entryCount; i++) { + const size = readUint32(stsdBox, entryOffset); + const fourCC = bin2str(stsdBox.subarray(entryOffset + 4, entryOffset + 8)); + const entry = stsdBox.subarray(entryOffset, entryOffset + size); + let boxType: Uint8Array | undefined = undefined; + switch (fourCC) { + case 'avc1': + case 'avc2': + case 'avc3': + case 'avc4': + boxType = new Uint8Array([0x65, 0x6e, 0x63, 0x76]); // 'encv' + break; + case 'mp4a': + boxType = new Uint8Array([0x65, 0x6e, 0x63, 0x61]); // 'enca' + break; + default: + break; + } + + if (boxType) { + const encEntry = createFakeSinfBox(boxType, fourCC, entry); + // For Xbox One & Edge, we cut and insert at the start of the source box. + // For other platforms, we cut and insert at the end of the source box. It's + // not clear why this is necessary on Xbox One, but it seems to be evidence + // of another bug in the firmware implementation of MediaSource & EME. + // TODO: needs more tests + if (navigator.userAgent.match(/Edge?\//)) { + newEntries.push(encEntry); + newEntries.push(entry); + } else { + newEntries.push(entry); + newEntries.push(encEntry); + } + } else { + newEntries.push(entry); + } + + entryOffset += size; + } + + // Rebuild stsd box with new entries + const stsdHeader = stsdBox.subarray(0, 8); + writeUint32(stsdHeader, 4, newEntries.length); + const newStsd = mp4Box([0x73, 0x74, 0x73, 0x64], stsdHeader, ...newEntries); + const stsdOffset = stsdBox.byteOffset - trak.byteOffset - 8; + + // Update sizes of parent boxes + writeUint32( + trak, + stbl.byteOffset - trak.byteOffset - 8, + stbl.length - stsdBox.length + newStsd.length, + ); + writeUint32( + trak, + minf.byteOffset - trak.byteOffset - 8, + minf.length - stsdBox.length + newStsd.length, + ); + writeUint32( + trak, + mdia.byteOffset - trak.byteOffset - 8, + mdia.length - stsdBox.length + newStsd.length, + ); + + // Rebuild trak with patched stsd box + let patchedTrak = trak; + if (stsdOffset > 0) { + patchedTrak = new Uint8Array( + trak.length - stsdBox.length + newStsd.length - 8, + ); + patchedTrak.set(trak.subarray(0, stsdOffset), 0); + patchedTrak.set(newStsd, stsdOffset); + patchedTrak.set( + trak.subarray(stsdOffset + stsdBox.length + 8), + stsdOffset + newStsd.length, + ); + } + + // Rebuild moov with patched trak + let moovRest = moov; + const trakOffset = trak.byteOffset - moov.byteOffset; + writeUint32(moovRest, trakOffset - 8, patchedTrak.length + 8); + + if (trakOffset > 0) { + const before = moovRest.subarray(0, trakOffset); + const after = moovRest.subarray(trakOffset + trak.length); + const newMoov = new Uint8Array( + before.length + patchedTrak.length + after.length, + ); + newMoov.set(before, 0); + newMoov.set(patchedTrak, before.length); + newMoov.set(after, before.length + patchedTrak.length); + moovRest = newMoov; + } + + const patchedMoov = new Uint8Array(8 + moovRest.length); + patchedMoov.set( + initSegment.subarray(moov.byteOffset - 8, moov.byteOffset), + 0, + ); + patchedMoov.set(moovRest, 8); + writeUint32(patchedMoov, 0, moovRest.length + 8); + + // Now reconstruct the full MP4, replacing only the moov atom + const out: Uint8Array[] = []; + let offset = 0; + while (offset < initSegment.length) { + const size = readUint32(initSegment, offset); + const type = bin2str(initSegment.subarray(offset + 4, offset + 8)); + if (type === 'moov') { + out.push(patchedMoov); + } else { + out.push(initSegment.subarray(offset, offset + size)); + } + offset += size; + } + + const modifiedInitSegment = appendUint8Array(out[0], out[1]); + + // Edge Windows needs the unmodified init segment to be appended after the + // patched one, otherwise video element throws following error: + // CHUNK_DEMUXER_ERROR_APPEND_FAILED: Sample encryption info is not + // available. + if (navigator.userAgent.match(/Edge?\//)) { + return appendUint8Array(modifiedInitSegment, initSegment); + } else { + return modifiedInitSegment; + } +} diff --git a/tests/test-streams.js b/tests/test-streams.js index ec12f9ac926..af71c9923bd 100644 --- a/tests/test-streams.js +++ b/tests/test-streams.js @@ -23,6 +23,30 @@ function createTestStream( }; } +function getLicenseAuthToken() { + return fetch( + 'https://shield-api.imggaming.com/admin/v1/ovp/dice/client/dce.sandbox/action/sign_test_content_token', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ttl_seconds: 30000, + claims: { + eid: '52c6a05f-d29b-48a1-85f2-fe99d6839947', + aid: 'a25cb2dd-afd3-40a9-ae23-6ea4fed33354', + did: 'c2ee36fd-e7d7-436f-915a-37ea456f4d16', + def: 'uhd2', + // mhd: 'hd', + }, + }), + }, + ).then(function (response) { + return response.json(); + }); +} + /** * @param {Object} target * @param {Object} [config] @@ -290,4 +314,135 @@ module.exports = { abr: false, skipFunctionalTests: true, }, + issue7413_widevine_multi_keys: createTestStreamWithConfig( + { + url: 'https://sample-videos-zyrkp2nj.s3-eu-west-1.amazonaws.com/big-buck-bunny-variants/30fps-multi-key/hls_fmp4_cenc_pw/master.m3u8', + description: '#7413 widevine multi keys"', + abr: true, + skip_ua: ['firefox', 'safari'], + }, + { + emeEnabled: true, + drmSystems: { + 'com.widevine.alpha': { + licenseUrl: 'https://shield-drm.imggaming.com/api/v2/license', + }, + }, + licenseXhrSetup: async function (xhr, url, keyContext, licenseChallenge) { + const { token } = await getLicenseAuthToken(); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.setRequestHeader( + 'x-drm-info', + 'eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==', + ); + }, + }, + ), + issue7413_playready_multi_keys: createTestStreamWithConfig( + { + url: 'https://sample-videos-zyrkp2nj.s3-eu-west-1.amazonaws.com/big-buck-bunny-variants/30fps-multi-key/hls_fmp4_cenc_pw/master.m3u8', + description: '#7413 playready multi keys"', + abr: true, + skip_ua: ['firefox', 'safari'], + }, + { + emeEnabled: true, + drmSystems: { + 'com.microsoft.playready': { + licenseUrl: 'https://shield-drm.imggaming.com/api/v2/license', + }, + }, + licenseXhrSetup: async function (xhr, url, keyContext, licenseChallenge) { + const { token } = await getLicenseAuthToken(); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.setRequestHeader( + 'x-drm-info', + 'eyJzeXN0ZW0iOiJjb20ubWljcm9zb2Z0LnBsYXlyZWFkeSJ9', + ); + }, + requestMediaKeySystemAccessFunc: (keySystem, supportedConfigurations) => { + if (keySystem === 'com.microsoft.playready') { + keySystem = 'com.microsoft.playready.recommendation'; + } + return navigator.requestMediaKeySystemAccess( + keySystem, + supportedConfigurations, + ); + }, + }, + ), + issue7413_playready_clear_to_drm: createTestStreamWithConfig( + { + url: 'https://sample-videos-zyrkp2nj.s3.eu-west-1.amazonaws.com/big-buck-bunny-fmp4-cbcs-clear-to-drm/ref/master_clear_to_drm.m3u8', + description: '#7413 playready clear to drm', + abr: true, + skip_ua: ['firefox', 'safari'], + }, + { + emeEnabled: true, + requiresEncryptionInfoInAllInitSegments: true, + drmSystems: { + 'com.microsoft.playready': { + licenseUrl: 'https://shield-drm.imggaming.com/api/v2/license', + }, + }, + licenseXhrSetup: async function (xhr, url, keyContext, licenseChallenge) { + const { token } = await getLicenseAuthToken(); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.setRequestHeader( + 'x-drm-info', + 'eyJzeXN0ZW0iOiJjb20ubWljcm9zb2Z0LnBsYXlyZWFkeSJ9', + ); + }, + requestMediaKeySystemAccessFunc: (keySystem, supportedConfigurations) => { + if (keySystem === 'com.microsoft.playready') { + keySystem = 'com.microsoft.playready.recommendation'; + } + return navigator.requestMediaKeySystemAccess( + keySystem, + supportedConfigurations, + ); + }, + }, + ), + issue7413_widevine_clear_to_drm: createTestStreamWithConfig( + { + url: 'https://sample-videos-zyrkp2nj.s3.eu-west-1.amazonaws.com/big-buck-bunny-fmp4-cbcs-clear-to-drm/ref/master_clear_to_drm_348000.m3u8', + description: '#7413 widevine clear to drm', + abr: true, + skip_ua: ['firefox', 'safari'], + }, + { + emeEnabled: true, + drmSystems: { + 'com.widevine.alpha': { + licenseUrl: 'https://shield-drm.imggaming.com/api/v2/license', + }, + }, + licenseXhrSetup: async function (xhr, url, keyContext, licenseChallenge) { + const { token } = await getLicenseAuthToken(); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.setRequestHeader( + 'x-drm-info', + 'eyJzeXN0ZW0iOiJjb20ud2lkZXZpbmUuYWxwaGEifQ==', + ); + }, + }, + ), + issue7413_widevine_clear_to_drm_shaka: createTestStreamWithConfig( + { + url: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8', + description: '#7413 widevine clear to drm shaka', + abr: true, + skip_ua: ['firefox', 'safari'], + }, + { + emeEnabled: true, + drmSystems: { + 'com.widevine.alpha': { + licenseUrl: 'https://cwip-shaka-proxy.appspot.com/no_auth', + }, + }, + }, + ), };