UNPKG

shaka-player

Version:
734 lines (634 loc) 24.3 kB
/** * @license * Copyright 2016 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ describe('StreamingEngine', () => { const ContentType = shaka.util.ManifestParserUtils.ContentType; const Util = shaka.test.Util; let metadata; let generators; /** @type {!shaka.util.EventManager} */ let eventManager; /** @type {!HTMLVideoElement} */ let video; let timeline; /** @type {!shaka.media.Playhead} */ let playhead; /** @type {shaka.extern.StreamingConfiguration} */ let config; let netEngine; /** @type {!shaka.media.MediaSourceEngine} */ let mediaSourceEngine; /** @type {!shaka.media.StreamingEngine} */ let streamingEngine; /** @type {shaka.extern.Variant} */ let variant1; /** @type {shaka.extern.Variant} */ let variant2; /** @type {shaka.extern.Manifest} */ let manifest; /** @type {!jasmine.Spy} */ let onBuffering; /** @type {!jasmine.Spy} */ let onChooseStreams; /** @type {!jasmine.Spy} */ let onCanSwitch; /** @type {!jasmine.Spy} */ let onError; /** @type {!jasmine.Spy} */ let onEvent; /** @type {!jasmine.Spy} */ let onInitialStreamsSetup; /** @type {!jasmine.Spy} */ let onStartupComplete; beforeAll(() => { video = shaka.util.Dom.createVideoElement(); document.body.appendChild(video); metadata = shaka.test.TestScheme.DATA['sintel']; generators = {}; }); beforeEach(() => { config = shaka.util.PlayerConfiguration.createDefault().streaming; onChooseStreams = jasmine.createSpy('onChooseStreams'); onCanSwitch = jasmine.createSpy('onCanSwitch'); onInitialStreamsSetup = jasmine.createSpy('onInitialStreamsSetup'); onStartupComplete = jasmine.createSpy('onStartupComplete'); onError = jasmine.createSpy('onError'); onError.and.callFake(fail); onEvent = jasmine.createSpy('onEvent'); eventManager = new shaka.util.EventManager(); mediaSourceEngine = new shaka.media.MediaSourceEngine( video, new shaka.test.FakeClosedCaptionParser(), new shaka.test.FakeTextDisplayer()); }); afterEach(async () => { eventManager.release(); await streamingEngine.destroy(); await mediaSourceEngine.destroy(); playhead.release(); }); afterAll(() => { document.body.removeChild(video); }); async function setupVod() { await createVodStreamGenerator(metadata.audio, ContentType.AUDIO); await createVodStreamGenerator(metadata.video, ContentType.VIDEO); timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline( 0 /* segmentAvailabilityStart */, 60 /* segmentAvailabilityEnd */, 60 /* presentationDuration */, metadata.video.segmentDuration /* maxSegmentDuration */, false /* isLive */); setupNetworkingEngine( 0 /* firstPeriodStartTime */, 30 /* secondPeriodStartTime */, 60 /* presentationDuration */, {audio: metadata.audio.segmentDuration, video: metadata.video.segmentDuration}); setupManifest( 0 /* firstPeriodStartTime */, 30 /* secondPeriodStartTime */, 60 /* presentationDuration */); setupPlayhead(); createStreamingEngine(); } async function setupLive() { await createLiveStreamGenerator( metadata.audio, ContentType.AUDIO, 20 /* timeShiftBufferDepth */); await createLiveStreamGenerator( metadata.video, ContentType.VIDEO, 20 /* timeShiftBufferDepth */); // The generator's AST is set to 295 seconds in the past, so the live-edge // is at 295 - 10 seconds. // -10 to account for maxSegmentDuration. timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline( 275 - 10 /* segmentAvailabilityStart */, 295 - 10 /* segmentAvailabilityEnd */, Infinity /* presentationDuration */, metadata.video.segmentDuration /* maxSegmentDuration */, true /* isLive */); setupNetworkingEngine( 0 /* firstPeriodStartTime */, 300 /* secondPeriodStartTime */, Infinity /* presentationDuration */, {audio: metadata.audio.segmentDuration, video: metadata.video.segmentDuration}); setupManifest( 0 /* firstPeriodStartTime */, 300 /* secondPeriodStartTime */, Infinity /* presentationDuration */); setupPlayhead(); createStreamingEngine(); } function createVodStreamGenerator(metadata, type) { let generator = new shaka.test.Mp4VodStreamGenerator( metadata.initSegmentUri, metadata.mdhdOffset, metadata.segmentUri, metadata.tfdtOffset, metadata.segmentDuration, metadata.presentationTimeOffset); generators[type] = generator; return generator.init(); } function createLiveStreamGenerator(metadata, type, timeShiftBufferDepth) { // Set the generator's AST to 295 seconds in the past so the // StreamingEngine begins streaming close to the end of the first Period. let now = Date.now() / 1000; let generator = new shaka.test.Mp4LiveStreamGenerator( metadata.initSegmentUri, metadata.mdhdOffset, metadata.segmentUri, metadata.tfdtOffset, metadata.segmentDuration, metadata.presentationTimeOffset, now - 295 /* broadcastStartTime */, now - 295 /* availabilityStartTime */, timeShiftBufferDepth); generators[type] = generator; return generator.init(); } function setupNetworkingEngine(firstPeriodStartTime, secondPeriodStartTime, presentationDuration, segmentDurations) { let periodStartTimes = [firstPeriodStartTime, secondPeriodStartTime]; let boundsCheckPosition = shaka.test.StreamingEngineUtil.boundsCheckPosition.bind( null, periodStartTimes, presentationDuration, segmentDurations); let getNumSegments = shaka.test.StreamingEngineUtil.getNumSegments.bind( null, periodStartTimes, presentationDuration, segmentDurations); // Create the fake NetworkingEngine. Note: the StreamingEngine should never // request a segment that does not exist. netEngine = shaka.test.StreamingEngineUtil.createFakeNetworkingEngine( // Init segment generator: function(type, periodNumber) { expect(periodNumber).toBeLessThan(periodStartTimes.length + 1); let wallClockTime = Date.now() / 1000; let segment = generators[type].getInitSegment(wallClockTime); expect(segment).not.toBeNull(); return segment; }, // Media segment generator: function(type, periodNumber, position) { expect(boundsCheckPosition(type, periodNumber, position)) .not.toBeNull(); // Compute the total number of segments in all Periods before the // |periodNumber|'th one. let numPriorSegments = 0; for (let n = 1; n < periodNumber; ++n) { numPriorSegments += getNumSegments(type, n); } let wallClockTime = Date.now() / 1000; let segment = generators[type].getSegment( position, numPriorSegments, wallClockTime); expect(segment).not.toBeNull(); return segment; }); } function setupPlayhead() { onBuffering = jasmine.createSpy('onBuffering'); let onSeek = () => { streamingEngine.seeked(); }; playhead = new shaka.media.MediaSourcePlayhead( /** @type {!HTMLVideoElement} */(video), manifest, config, null /* startTime */, onSeek, shaka.test.Util.spyFunc(onEvent)); } function setupManifest( firstPeriodStartTime, secondPeriodStartTime, presentationDuration) { manifest = shaka.test.StreamingEngineUtil.createManifest( [firstPeriodStartTime, secondPeriodStartTime], presentationDuration, {audio: metadata.audio.segmentDuration, video: metadata.video.segmentDuration}); manifest.presentationTimeline = /** @type {!shaka.media.PresentationTimeline} */ (timeline); manifest.minBufferTime = 2; // Create InitSegmentReferences. function makeUris(uri) { return () => { return [uri]; }; } manifest.periods[0].variants[0].audio.initSegmentReference = new shaka.media.InitSegmentReference(makeUris('1_audio_init'), 0, null); manifest.periods[0].variants[0].video.initSegmentReference = new shaka.media.InitSegmentReference(makeUris('1_video_init'), 0, null); manifest.periods[1].variants[0].audio.initSegmentReference = new shaka.media.InitSegmentReference(makeUris('2_audio_init'), 0, null); manifest.periods[1].variants[0].video.initSegmentReference = new shaka.media.InitSegmentReference(makeUris('2_video_init'), 0, null); variant1 = manifest.periods[0].variants[0]; variant2 = manifest.periods[1].variants[0]; } function createStreamingEngine() { let playerInterface = { getPresentationTime: () => playhead.getTime(), getBandwidthEstimate: () => 1e6, mediaSourceEngine: mediaSourceEngine, netEngine: /** @type {!shaka.net.NetworkingEngine} */(netEngine), onChooseStreams: Util.spyFunc(onChooseStreams), onCanSwitch: Util.spyFunc(onCanSwitch), onError: Util.spyFunc(onError), onEvent: Util.spyFunc(onEvent), onManifestUpdate: () => {}, onSegmentAppended: () => playhead.notifyOfBufferingChange(), onInitialStreamsSetup: Util.spyFunc(onInitialStreamsSetup), onStartupComplete: Util.spyFunc(onStartupComplete), }; streamingEngine = new shaka.media.StreamingEngine( /** @type {shaka.extern.Manifest} */(manifest), playerInterface); streamingEngine.configure(config); } describe('VOD', () => { beforeEach(async () => { await setupVod(); }); it('plays', async () => { onStartupComplete.and.callFake(() => { video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await reachesTheEnd(); }); it('plays at high playback rates', async () => { // Experimentally, we find that playback rates above 2x in this test seem // to cause decoder failures on Tizen 3. This is out of our control, and // seems to be a Tizen bug, so this test is skipped on Tizen completely. if (shaka.util.Platform.isTizen()) { pending('High playbackRate tests cause decoder errors on Tizen 3.'); } let startupComplete = false; onStartupComplete.and.callFake(() => { startupComplete = true; video.play(); }); onBuffering.and.callFake(function(buffering) { if (!buffering) { expect(startupComplete).toBeTruthy(); video.playbackRate = 10; } }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await reachesTheEnd(); }); it('can handle buffered seeks', async () => { onStartupComplete.and.callFake(() => { video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); // After 35 seconds seek back 10 seconds into the first Period. await passesTime(35); video.currentTime = 25; await reachesTheEnd(); }); it('can handle unbuffered seeks', async () => { onStartupComplete.and.callFake(() => { video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await passesTime(20); video.currentTime = 40; await reachesTheEnd(); }); }); describe('Live', () => { let slideSegmentAvailabilityWindow; beforeEach(async () => { await setupLive(); slideSegmentAvailabilityWindow = window.setInterval(() => { timeline.segmentAvailabilityStart++; timeline.segmentAvailabilityEnd++; }, 1000); }); afterEach(() => { window.clearInterval(slideSegmentAvailabilityWindow); }); it('plays through Period transition', async () => { onStartupComplete.and.callFake(() => { // firstSegmentNumber = // [(segmentAvailabilityEnd - rebufferingGoal) / segmentDuration] + 1 // Then -1 to account for drift safe buffering. const segmentType = shaka.net.NetworkingEngine.RequestType.SEGMENT; netEngine.expectRequest('1_video_28', segmentType); netEngine.expectRequest('1_audio_28', segmentType); video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await passesTime(305); }); it('can handle seeks ahead of availability window', async () => { const startUpCompleted = new Promise((resolve) => { onStartupComplete.and.callFake(() => { video.play(); resolve(); }); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await startUpCompleted; // Seek outside the availability window right away. The playhead // should adjust the video's current time. video.currentTime = timeline.segmentAvailabilityEnd + 120; // Wait until the repositioning is complete so we don't // immediately hit this case. await shaka.test.Util.delay(/* seconds= */ 1); await passesTime(305); }); it('can handle seeks behind availability window', async () => { onStartupComplete.and.callFake(() => { video.play(); // Use setTimeout to ensure the playhead has performed it's initial // seeking. setTimeout(() => { // Seek outside the availability window right away. The playhead // should adjust the video's current time. video.currentTime = timeline.segmentAvailabilityStart - 120; expect(video.currentTime).toBeGreaterThan(0); }, 50); }); let seekCount = 0; eventManager.listen(video, 'seeking', () => { seekCount++; }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await passesTime(305); // We are playing close to the beginning of the availability window. // We should be playing smoothly and not seeking repeatedly as we fall // outside the window. // // Expected seeks: // 1. seek to live stream start time during startup // 2. explicit seek in the test to get outside the window // 3. Playhead seeks to force us back inside the window // 4. (maybe) seek if there is a gap at the period boundary // 5. (maybe) seek to flush a pipeline stall expect(seekCount).toBeGreaterThan(2); expect(seekCount).toBeLessThan(6); }); }); // This tests gaps created by missing segments. // TODO: Consider also adding tests for missing frames. describe('gap jumping', () => { it('jumps small gaps at the beginning', async () => { config.smallGapLimit = 5; await setupGappyContent(/* gapAtStart */ 1, /* dropSegment */ false); onStartupComplete.and.callFake(() => { expect(video.buffered.length).toBeGreaterThan(0); expect(video.buffered.start(0)).toBeCloseTo(1); video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await passesTime(5); }); it('jumps large gaps at the beginning', async () => { config.smallGapLimit = 1; config.jumpLargeGaps = true; await setupGappyContent(/* gapAtStart */ 5, /* dropSegment */ false); onStartupComplete.and.callFake(() => { expect(video.buffered.length).toBeGreaterThan(0); expect(video.buffered.start(0)).toBeCloseTo(5); video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await passesTime(8); }); it('jumps small gaps in the middle', async () => { config.smallGapLimit = 20; await setupGappyContent(/* gapAtStart */ 0, /* dropSegment */ true); onStartupComplete.and.callFake(() => { video.currentTime = 8; video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await passesTime(23); // Should be close enough to still have the gap buffered. expect(video.buffered.length).toBe(2); expect(onEvent).not.toHaveBeenCalled(); }); it('jumps large gaps in the middle', async () => { config.jumpLargeGaps = true; await setupGappyContent(/* gapAtStart */ 0, /* dropSegment */ true); onStartupComplete.and.callFake(() => { video.currentTime = 8; video.play(); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); await streamingEngine.start(); await passesTime(23); // Should be close enough to still have the gap buffered. expect(video.buffered.length).toBe(2); expect(onEvent).toHaveBeenCalled(); }); it('won\'t jump large gaps with preventDefault()', function(done) { config.jumpLargeGaps = true; setupGappyContent(/* gapAtStart */ 0, /* dropSegment */ true) .then(() => { onStartupComplete.and.callFake(() => { video.currentTime = 8; video.play(); }); onEvent.and.callFake(function(event) { event.preventDefault(); shaka.test.Util.delay(5).then(() => { // IE/Edge somehow plays inside the gap. Just make sure we // don't jump the gap. expect(video.currentTime).toBeLessThan(20); done(); }) .catch(done.fail); }); // Let's go! onChooseStreams.and.callFake(defaultOnChooseStreams); return streamingEngine.start(); }).catch(done.fail); }); /** * @param {number} gapAtStart The gap to introduce before start, in seconds. * @param {boolean} dropSegment Whether to drop a segment in the middle. * @return {!Promise} */ async function setupGappyContent(gapAtStart, dropSegment) { // This uses "normal" stream generators and networking engine. The only // difference is the segments are removed from the manifest. The segments // should not be downloaded. await createVodStreamGenerator(metadata.audio, ContentType.AUDIO); await createVodStreamGenerator(metadata.video, ContentType.VIDEO); timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline( 0 /* segmentAvailabilityStart */, 30 /* segmentAvailabilityEnd */, 30 /* presentationDuration */, metadata.video.segmentDuration /* maxSegmentDuration */, false /* isLive */); setupNetworkingEngine( 0 /* firstPeriodStartTime */, 30 /* secondPeriodStartTime */, 30 /* presentationDuration */, {audio: metadata.audio.segmentDuration, video: metadata.video.segmentDuration}); manifest = setupGappyManifest(gapAtStart, dropSegment); variant1 = manifest.periods[0].variants[0]; setupPlayhead(); createStreamingEngine(); } /** * TODO: Consolidate with StreamingEngineUtils.createManifest? * @param {number} gapAtStart * @param {boolean} dropSegment * @return {shaka.extern.Manifest} */ function setupGappyManifest(gapAtStart, dropSegment) { /** * @param {string} type * @return {!shaka.media.SegmentIndex} */ function createIndex(type) { let d = metadata[type].segmentDuration; let refs = []; let i = 1; let time = gapAtStart; while (time < 30) { let end = time + d; // Make segment 1 longer to make the manifest continuous, despite the // dropped segment. if (i == 1 && dropSegment) { end += d; } let getUris = (function(i) { // The times in the media are based on the URL; so to drop a // segment, we change the URL. if (i >= 2 && dropSegment) i++; return ['1_' + type + '_' + i]; }.bind(null, i)); refs.push( new shaka.media.SegmentReference(i, time, end, getUris, 0, null)); i++; time = end; } return new shaka.media.SegmentIndex(refs); } function createInit(type) { let getUris = () => { return ['1_' + type + '_init']; }; return new shaka.media.InitSegmentReference(getUris, 0, null); } let videoIndex = createIndex('video'); let audioIndex = createIndex('audio'); return { presentationTimeline: timeline, offlineSessionIds: [], minBufferTime: 2, periods: [{ startTime: 0, textStreams: [], variants: [{ id: 1, video: { id: 2, createSegmentIndex: Promise.resolve.bind(Promise), findSegmentPosition: videoIndex.find.bind(videoIndex), getSegmentReference: videoIndex.get.bind(videoIndex), initSegmentReference: createInit('video'), // Normally PTO adjusts the segment time backwards; so to make the // segment appear in the future, use a negative. presentationTimeOffset: -gapAtStart, mimeType: 'video/mp4', codecs: 'avc1.42c01e', bandwidth: 5000000, width: 600, height: 400, type: shaka.util.ManifestParserUtils.ContentType.VIDEO, }, audio: { id: 3, createSegmentIndex: Promise.resolve.bind(Promise), findSegmentPosition: audioIndex.find.bind(audioIndex), getSegmentReference: audioIndex.get.bind(audioIndex), initSegmentReference: createInit('audio'), presentationTimeOffset: -gapAtStart, mimeType: 'audio/mp4', codecs: 'mp4a.40.2', bandwidth: 192000, type: shaka.util.ManifestParserUtils.ContentType.AUDIO, }, }], }], }; } }); /** * Choose streams for the given period. * * @param {shaka.extern.Period} period * @return {!Object.<string, !shaka.extern.Stream>} */ function defaultOnChooseStreams(period) { if (period == manifest.periods[0]) { return {variant: variant1, text: null}; } else if (period == manifest.periods[1]) { return {variant: variant2, text: null}; } else { throw new Error(); } } /** * @param {number} seconds * @return {!Promise} */ function passesTime(seconds) { return new Promise((resolve) => { eventManager.listen(video, 'timeupdate', () => { if (video.currentTime >= seconds) { resolve(); } }); }); } /** * @return {!Promise} */ function reachesTheEnd() { // Safari has a bug where it sometimes doesn't fire the 'ended' event, // so use 'timeupdate' instead. return new Promise((resolve) => { eventManager.listen(video, 'timeupdate', () => { if (video.ended) { resolve(); } }); }); } });