Skip to content

Commit 5f18231

Browse files
tykus160aveladgkatsev
authored
fix: Mixed clear/encrypted playback on Safari MSE (#8354)
Fixes #8335 --------- Co-authored-by: Álvaro Velad Galván <[email protected]> Co-authored-by: Gary Katsevman <[email protected]>
1 parent 8e0d45f commit 5f18231

26 files changed

+507
-164
lines changed

karma.conf.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ module.exports = (config) => {
254254
{pattern: 'test/**/*.js', included: false},
255255
{pattern: 'test/test/assets/*', included: false},
256256
{pattern: 'test/test/assets/clear-encrypted/*', included: false},
257+
{pattern: 'test/test/assets/clear-encrypted-hls/*', included: false},
257258
{pattern: 'test/test/assets/dash-multi-codec/*', included: false},
258259
{pattern: 'test/test/assets/dash-multi-codec-ec3/*', included: false},
259260
{pattern: 'test/test/assets/3675/*', included: false},

lib/media/content_workarounds.js

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ goog.require('goog.asserts');
1010
goog.require('shaka.log');
1111
goog.require('shaka.util.BufferUtils');
1212
goog.require('shaka.util.Error');
13+
goog.require('shaka.util.Mp4BoxParsers');
1314
goog.require('shaka.util.Mp4Generator');
1415
goog.require('shaka.util.Mp4Parser');
1516
goog.require('shaka.util.Platform');
@@ -149,6 +150,111 @@ shaka.media.ContentWorkarounds = class {
149150
return modifiedInitSegment;
150151
}
151152

153+
/**
154+
* @param {!BufferSource} mediaSegmentBuffer
155+
* @return {!Uint8Array}
156+
*/
157+
static fakeMediaEncryption(mediaSegmentBuffer) {
158+
const mediaSegment = shaka.util.BufferUtils.toUint8(mediaSegmentBuffer);
159+
const mdatBoxes = [];
160+
new shaka.util.Mp4Parser()
161+
.box('mdat', (box) => {
162+
mdatBoxes.push(box);
163+
})
164+
.parse(mediaSegment);
165+
166+
const newSegmentChunks = [];
167+
for (let i = 0; i < mdatBoxes.length; i++) {
168+
const prevMdat = mdatBoxes[i - 1];
169+
const currMdat = mdatBoxes[i];
170+
const chunkStart = prevMdat ? prevMdat.start + prevMdat.size : 0;
171+
const chunkEnd = currMdat.start + currMdat.size;
172+
const chunk = mediaSegment.subarray(chunkStart, chunkEnd);
173+
newSegmentChunks.push(
174+
shaka.media.ContentWorkarounds.fakeMediaEncryptionInChunk_(chunk));
175+
}
176+
return shaka.util.Uint8ArrayUtils.concat(...newSegmentChunks);
177+
}
178+
179+
/**
180+
* @param {!Uint8Array} chunk
181+
* @return {!Uint8Array}
182+
* @private
183+
*/
184+
static fakeMediaEncryptionInChunk_(chunk) {
185+
// Which track from stsd we want to use, 1-based.
186+
const desiredSampleDescriptionIndex = 2;
187+
let tfhdBox;
188+
let trunBox;
189+
let parsedTfhd;
190+
let parsedTrun;
191+
const ancestorBoxes = [];
192+
const onSimpleAncestorBox = (box) => {
193+
ancestorBoxes.push(box);
194+
shaka.util.Mp4Parser.children(box);
195+
};
196+
const onTfhdBox = (box) => {
197+
tfhdBox = box;
198+
parsedTfhd = shaka.util.Mp4BoxParsers.parseTFHD(box.reader, box.flags);
199+
};
200+
const onTrunBox = (box) => {
201+
trunBox = box;
202+
parsedTrun = shaka.util.Mp4BoxParsers.parseTRUN(box.reader, box.version,
203+
box.flags);
204+
};
205+
new shaka.util.Mp4Parser()
206+
.box('moof', onSimpleAncestorBox)
207+
.box('traf', onSimpleAncestorBox)
208+
.fullBox('tfhd', onTfhdBox)
209+
.fullBox('trun', onTrunBox)
210+
.parse(chunk);
211+
if (parsedTfhd && parsedTfhd.sampleDescriptionIndex !==
212+
desiredSampleDescriptionIndex) {
213+
const sdiPosition = tfhdBox.start +
214+
shaka.util.Mp4Parser.headerSize(tfhdBox) +
215+
4 + // track_id
216+
(parsedTfhd.baseDataOffset !== null ? 8 : 0);
217+
const dataview = shaka.util.BufferUtils.toDataView(chunk);
218+
if (parsedTfhd.sampleDescriptionIndex !== null) {
219+
dataview.setUint32(sdiPosition, desiredSampleDescriptionIndex);
220+
} else {
221+
const sdiSize = 4; // uint32
222+
223+
// first, update size & flags of tfhd
224+
shaka.media.ContentWorkarounds.updateBoxSize_(chunk,
225+
tfhdBox.start, tfhdBox.size + sdiSize);
226+
const versionAndFlags = dataview.getUint32(tfhdBox.start + 8);
227+
dataview.setUint32(tfhdBox.start + 8, versionAndFlags | 0x000002);
228+
229+
// second, update trun
230+
if (parsedTrun && parsedTrun.dataOffset !== null) {
231+
const newDataOffset = parsedTrun.dataOffset + sdiSize;
232+
const dataOffsetPosition = trunBox.start +
233+
shaka.util.Mp4Parser.headerSize(trunBox) +
234+
4; // sample count
235+
dataview.setInt32(dataOffsetPosition, newDataOffset);
236+
}
237+
const beforeSdi = chunk.subarray(0, sdiPosition);
238+
const afterSdi = chunk.subarray(sdiPosition);
239+
chunk = new Uint8Array(chunk.byteLength + sdiSize);
240+
chunk.set(beforeSdi);
241+
242+
const bytes = [];
243+
for (let byte = sdiSize - 1; byte >= 0; byte--) {
244+
bytes.push((desiredSampleDescriptionIndex >> (8 * byte)) & 0xff);
245+
}
246+
chunk.set(new Uint8Array(bytes), sdiPosition);
247+
chunk.set(afterSdi, sdiPosition + sdiSize);
248+
for (const box of ancestorBoxes) {
249+
shaka.media.ContentWorkarounds.updateBoxSize_(chunk, box.start,
250+
box.size + sdiSize);
251+
}
252+
}
253+
}
254+
255+
return chunk;
256+
}
257+
152258
/**
153259
* Insert an encryption metadata box ("encv" or "enca" box) into the MP4 init
154260
* segment, based on the source box ("mp4a", "avc1", etc). Returns a new
@@ -178,8 +284,8 @@ shaka.media.ContentWorkarounds = class {
178284
// For other platforms, we cut and insert at the end of the source box. It's
179285
// not clear why this is necessary on Xbox One, but it seems to be evidence
180286
// of another bug in the firmware implementation of MediaSource & EME.
181-
const cutPoint =
182-
(shaka.util.Platform.isXboxOne() || shaka.util.Platform.isEdge()) ?
287+
const cutPoint = (shaka.util.Platform.isApple() ||
288+
shaka.util.Platform.isXboxOne() || shaka.util.Platform.isEdge()) ?
183289
sourceBox.start :
184290
sourceBox.start + sourceBox.size;
185291

lib/media/media_source_engine.js

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,8 +1320,7 @@ shaka.media.MediaSourceEngine = class {
13201320
}
13211321

13221322
data = this.workAroundBrokenPlatforms_(
1323-
stream, data, reference ? reference.startTime : null, contentType,
1324-
reference ? reference.getUris()[0] : null);
1323+
stream, data, reference, contentType);
13251324

13261325
if (reference && this.sequenceMode_ && contentType != ContentType.TEXT) {
13271326
// In sequence mode, for non-text streams, if we just cleared the buffer
@@ -2141,44 +2140,54 @@ shaka.media.MediaSourceEngine = class {
21412140
*
21422141
* @param {shaka.extern.Stream} stream
21432142
* @param {!BufferSource} segment
2144-
* @param {?number} startTime
2143+
* @param {?shaka.media.SegmentReference} reference
21452144
* @param {shaka.util.ManifestParserUtils.ContentType} contentType
2146-
* @param {?string} uri
21472145
* @return {!BufferSource}
21482146
* @private
21492147
*/
2150-
workAroundBrokenPlatforms_(stream, segment, startTime, contentType, uri) {
2148+
workAroundBrokenPlatforms_(stream, segment, reference, contentType) {
21512149
const Platform = shaka.util.Platform;
21522150

2153-
const isInitSegment = startTime == null;
2151+
const isMp4 = shaka.util.MimeUtils.getContainerType(
2152+
this.sourceBufferTypes_.get(contentType)) == 'mp4';
2153+
if (!isMp4) {
2154+
return segment;
2155+
}
2156+
2157+
const isInitSegment = reference === null;
21542158
const encryptionExpected = this.expectedEncryption_.get(contentType);
21552159
const keySystem = this.playerInterface_.getKeySystem();
2160+
let isEncrypted = false;
2161+
if (reference && reference.initSegmentReference) {
2162+
isEncrypted = reference.initSegmentReference.encrypted;
2163+
}
2164+
const uri = reference ? reference.getUris()[0] : null;
21562165

21572166
// If:
21582167
// 1. the configuration tells to insert fake encryption,
2159-
// 2. and this is an init segment,
2168+
// 2. and this is an init segment or media segment,
21602169
// 3. and encryption is expected,
2161-
// 4. and the platform requires encryption in all init segments,
2162-
// 5. and the content is MP4 (mimeType == "video/mp4" or "audio/mp4"),
2170+
// 4. and the platform requires encryption in all init or media segments
2171+
// of current content type,
21632172
// then insert fake encryption metadata for init segments that lack it.
21642173
// The MP4 requirement is because we can currently only do this
21652174
// transformation on MP4 containers.
21662175
// See: https://github.com/shaka-project/shaka-player/issues/2759
2167-
if (this.config_.insertFakeEncryptionInInit &&
2168-
isInitSegment &&
2169-
encryptionExpected &&
2170-
Platform.requiresEncryptionInfoInAllInitSegments(keySystem) &&
2171-
shaka.util.MimeUtils.getContainerType(
2172-
this.sourceBufferTypes_.get(contentType)) == 'mp4') {
2173-
shaka.log.debug('Forcing fake encryption information in init segment.');
2174-
segment =
2175-
shaka.media.ContentWorkarounds.fakeEncryption(stream, segment, uri);
2176-
}
2177-
2178-
if (isInitSegment &&
2179-
Platform.requiresEC3InitSegments() &&
2180-
shaka.util.MimeUtils.getContainerType(
2181-
this.sourceBufferTypes_.get(contentType)) == 'mp4') {
2176+
if (this.config_.insertFakeEncryptionInInit && encryptionExpected &&
2177+
Platform.requiresEncryptionInfoInAllInitSegments(keySystem,
2178+
contentType)) {
2179+
if (isInitSegment) {
2180+
shaka.log.debug('Forcing fake encryption information in init segment.');
2181+
segment =
2182+
shaka.media.ContentWorkarounds.fakeEncryption(stream, segment, uri);
2183+
} else if (!isEncrypted && Platform.requiresTfhdFix(contentType)) {
2184+
shaka.log.debug(
2185+
'Forcing fake encryption information in media segment.');
2186+
segment = shaka.media.ContentWorkarounds.fakeMediaEncryption(segment);
2187+
}
2188+
}
2189+
2190+
if (isInitSegment && Platform.requiresEC3InitSegments()) {
21822191
shaka.log.debug('Forcing fake EC-3 information in init segment.');
21832192
segment = shaka.media.ContentWorkarounds.fakeEC3(segment);
21842193
}

lib/util/mp4_box_parsers.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,18 @@ shaka.util.Mp4BoxParsers = class {
2121
let defaultSampleDuration = null;
2222
let defaultSampleSize = null;
2323
let baseDataOffset = null;
24+
let sampleDescriptionIndex = null;
2425

2526
const trackId = reader.readUint32(); // Read "track_ID"
2627

27-
// Skip "base_data_offset" if present.
28+
// Read "base_data_offset" if present.
2829
if (flags & 0x000001) {
2930
baseDataOffset = reader.readUint64();
3031
}
3132

32-
// Skip "sample_description_index" if present.
33+
// Read "sample_description_index" if present.
3334
if (flags & 0x000002) {
34-
reader.skip(4);
35+
sampleDescriptionIndex = reader.readUint32();
3536
}
3637

3738
// Read "default_sample_duration" if present.
@@ -49,6 +50,7 @@ shaka.util.Mp4BoxParsers = class {
4950
defaultSampleDuration,
5051
defaultSampleSize,
5152
baseDataOffset,
53+
sampleDescriptionIndex,
5254
};
5355
}
5456

@@ -742,7 +744,8 @@ shaka.util.Mp4BoxParsers = class {
742744
* trackId: number,
743745
* defaultSampleDuration: ?number,
744746
* defaultSampleSize: ?number,
745-
* baseDataOffset: ?number
747+
* baseDataOffset: ?number,
748+
* sampleDescriptionIndex: ?number
746749
* }}
747750
*
748751
* @property {number} trackId
@@ -756,7 +759,8 @@ shaka.util.Mp4BoxParsers = class {
756759
* size in the Track Extends Box for this fragment
757760
* @property {?number} baseDataOffset
758761
* If specified via flags, this indicate the base data offset
759-
*
762+
* @property {?number} sampleDescriptionIndex
763+
* If specified via flags, this indicate the sample description index
760764
* @exportDoc
761765
*/
762766
shaka.util.ParsedTFHDBox;

lib/util/platform.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -621,16 +621,26 @@ shaka.util.Platform = class {
621621
* initialization segments.
622622
*
623623
* @param {?string} keySystem
624+
* @param {string} contentType
624625
* @return {boolean}
625626
* @see https://github.com/shaka-project/shaka-player/issues/2759
626627
*/
627-
static requiresEncryptionInfoInAllInitSegments(keySystem) {
628+
static requiresEncryptionInfoInAllInitSegments(keySystem, contentType) {
628629
const Platform = shaka.util.Platform;
629630
const isPlayReady = shaka.drm.DrmUtils.isPlayReadyKeySystem(keySystem);
630-
return Platform.isTizen() || Platform.isXboxOne() || Platform.isOrange() ||
631+
return (Platform.isApple() && contentType === 'audio') ||
632+
Platform.isTizen() || Platform.isXboxOne() || Platform.isOrange() ||
631633
(Platform.isEdge() && Platform.isWindows() && isPlayReady);
632634
}
633635

636+
/**
637+
* @param {string} contentType
638+
* @return {boolean}
639+
*/
640+
static requiresTfhdFix(contentType) {
641+
return shaka.util.Platform.isApple() && contentType === 'audio';
642+
}
643+
634644
/**
635645
* Returns true if the platform requires AC-3 signalling in init
636646
* segments to be replaced with EC-3 signalling.

lib/util/player_configuration.js

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,6 @@ shaka.util.PlayerConfiguration = class {
311311
shaka.config.CrossBoundaryStrategy.RESET_TO_ENCRYPTED;
312312
}
313313

314-
if (shaka.util.Platform.isApple()) {
315-
streaming.crossBoundaryStrategy =
316-
shaka.config.CrossBoundaryStrategy.RESET_ON_ENCRYPTION_CHANGE;
317-
}
318-
319314
const networking = {
320315
forceHTTP: false,
321316
forceHTTPS: false,

test/media/content_workarounds_integration.js

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,28 @@ describe('ContentWorkarounds', () => {
8282
await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 5, 30);
8383
});
8484

85-
for (const keySystem of ['com.widevine.alpha', 'com.microsoft.playready']) {
85+
const keySystemsConfigs = new Map()
86+
.set('com.widevine.alpha', {
87+
servers: {
88+
'com.widevine.alpha': 'https://cwip-shaka-proxy.appspot.com/no_auth',
89+
},
90+
})
91+
.set('com.microsoft.playready', {
92+
servers: {
93+
'com.microsoft.playready': 'http://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(kid:51745386-2d42-56fd-8bad-4f58422004d7,contentkey:UXRThi1CVv2LrU9YQiAE1w==),(kid:26470f42-96d4-5d04-a9ba-bb442e169800,contentkey:JkcPQpbUXQSpurtELhaYAA==)',
94+
},
95+
})
96+
.set('com.apple.fps', {
97+
servers: {
98+
'com.apple.fps': 'https://fps.ezdrm.com/api/licenses/b99ed9e5-c641-49d1-bfa8-43692b686ddb',
99+
},
100+
advanced: {
101+
'com.apple.fps': {
102+
serverCertificate: null, // empty now, fulfilled during actual test
103+
},
104+
},
105+
});
106+
for (const [keySystem, drmConfig] of keySystemsConfigs) {
86107
drmIt(`plays mixed clear encrypted content with ${keySystem}`, async () => {
87108
if (!shakaSupport.drm[keySystem]) {
88109
pending('Needed DRM is not supported on this platform');
@@ -91,30 +112,37 @@ describe('ContentWorkarounds', () => {
91112
pending('Tizen 3 currently does not support mixed clear ' +
92113
'encrypted content');
93114
}
115+
if (keySystem === 'com.apple.fps' && getClientArg('runningInVM')) {
116+
pending('FairPlay is not supported in a VM');
117+
}
94118
const keyStatusSpy = jasmine.createSpy('onKeyStatus');
95119
eventManager.listen(player, 'keystatuschanged',
96120
Util.spyFunc(keyStatusSpy));
97121

98-
const licenseUrl = keySystem == 'com.widevine.alpha' ?
99-
'https://cwip-shaka-proxy.appspot.com/no_auth' :
100-
'http://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(kid:51745386-2d42-56fd-8bad-4f58422004d7,contentkey:UXRThi1CVv2LrU9YQiAE1w==),(kid:26470f42-96d4-5d04-a9ba-bb442e169800,contentkey:JkcPQpbUXQSpurtELhaYAA==)';
101-
player.configure({
102-
drm: {
103-
servers: {
104-
[keySystem]: licenseUrl,
105-
},
106-
},
107-
});
108-
await player.load('/base/test/test/assets/clear-encrypted/manifest.mpd');
122+
if (keySystem === 'com.apple.fps') {
123+
const serverCert = await Util.fetch(
124+
'/base/test/test/assets/clear-encrypted-hls/certificate.cer');
125+
drmConfig.advanced[keySystem].serverCertificate =
126+
shaka.util.BufferUtils.toUint8(serverCert);
127+
}
128+
player.configure({drm: drmConfig});
129+
130+
const url = keySystem === 'com.apple.fps' ?
131+
'/base/test/test/assets/clear-encrypted-hls/manifest.m3u8' :
132+
'/base/test/test/assets/clear-encrypted/manifest.mpd';
133+
await player.load(url);
109134
await video.play();
110135

136+
// Ensure we're using MediaSource.
137+
expect(player.getLoadMode()).toBe(shaka.Player.LoadMode.MEDIA_SOURCE);
138+
111139
// Wait for the video to start playback. If it takes longer than 10
112140
// seconds, fail the test.
113141
await waiter.waitForMovementOrFailOnTimeout(video, 10);
114142

115-
// Play for 5 seconds, but stop early if the video ends. If it takes
143+
// Play for 10 seconds, but stop early if the video ends. If it takes
116144
// longer than 30 seconds, fail the test.
117-
await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 5, 30);
145+
await waiter.waitUntilPlayheadReachesOrFailOnTimeout(video, 10, 30);
118146

119147
// Check did we have key status change.
120148
expect(keyStatusSpy).toHaveBeenCalled();

0 commit comments

Comments
 (0)