Files
shaka-player/test/text/text_engine_unit.js
T
Joey Parrish 73c2ac72d3 Fix CEA timestamps with presentationTimeOffset
When CEA captions are extracted from video segments, the
presentationTimeOffset of the video segment should be applied to the
extracted captions, as well.

Fixes b/70902665

Change-Id: I446333a14b8b6374786ab594a579b6e18bc73ac1
2019-02-13 10:45:34 -08:00

492 lines
16 KiB
JavaScript

/**
* @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('TextEngine', function() {
const TextEngine = shaka.text.TextEngine;
const dummyData = new ArrayBuffer(0);
const dummyMimeType = 'text/fake';
/** @type {!Function} */
let mockParserPlugIn;
/** @type {!shaka.test.FakeTextDisplayer} */
let mockDisplayer;
/** @type {!jasmine.Spy} */
let mockParseInit;
/** @type {!jasmine.Spy} */
let mockParseMedia;
/** @type {!shaka.text.TextEngine} */
let textEngine;
beforeEach(function() {
mockParseInit = jasmine.createSpy('mockParseInit');
mockParseMedia = jasmine.createSpy('mockParseMedia');
mockParserPlugIn = function() {
return {
parseInit: mockParseInit,
parseMedia: mockParseMedia,
};
};
mockDisplayer = new shaka.test.FakeTextDisplayer();
mockDisplayer.removeSpy.and.returnValue(true);
TextEngine.registerParser(dummyMimeType, mockParserPlugIn);
textEngine = new TextEngine(mockDisplayer);
textEngine.initParser(dummyMimeType);
});
afterEach(function() {
TextEngine.unregisterParser(dummyMimeType);
});
describe('isTypeSupported', function() {
it('reports support only when a parser is installed', function() {
TextEngine.unregisterParser(dummyMimeType);
expect(TextEngine.isTypeSupported(dummyMimeType)).toBe(false);
TextEngine.registerParser(dummyMimeType, mockParserPlugIn);
expect(TextEngine.isTypeSupported(dummyMimeType)).toBe(true);
TextEngine.unregisterParser(dummyMimeType);
expect(TextEngine.isTypeSupported(dummyMimeType)).toBe(false);
});
it('reports support when it\'s closed captions and muxjs is available',
function() {
const closedCaptionsType =
shaka.util.MimeUtils.CLOSED_CAPTION_MIMETYPE;
const originalMuxjs = window.muxjs;
expect(TextEngine.isTypeSupported(closedCaptionsType)).toBe(true);
try {
window['muxjs'] = null;
expect(TextEngine.isTypeSupported(closedCaptionsType)).toBe(false);
} finally {
window['muxjs'] = originalMuxjs;
}
});
});
describe('appendBuffer', function() {
it('works asynchronously', function(done) {
mockParseMedia.and.returnValue([1, 2, 3]);
textEngine.appendBuffer(dummyData, 0, 3).catch(fail).then(done);
expect(mockDisplayer.appendSpy).not.toHaveBeenCalled();
});
it('calls displayer.append()', async () => {
let cue1 = createFakeCue(1, 2);
let cue2 = createFakeCue(2, 3);
let cue3 = createFakeCue(3, 4);
let cue4 = createFakeCue(4, 5);
mockParseMedia.and.returnValue([cue1, cue2]);
await textEngine.appendBuffer(dummyData, 0, 3);
expect(mockParseMedia).toHaveBeenCalledOnceMoreWith([
new Uint8Array(dummyData),
{periodStart: 0, segmentStart: 0, segmentEnd: 3},
]);
expect(mockDisplayer.appendSpy).toHaveBeenCalledOnceMoreWith([
[cue1, cue2],
]);
expect(mockDisplayer.removeSpy).not.toHaveBeenCalled();
mockParseMedia.and.returnValue([cue3, cue4]);
await textEngine.appendBuffer(dummyData, 3, 5);
expect(mockParseMedia).toHaveBeenCalledOnceMoreWith([
new Uint8Array(dummyData),
{periodStart: 0, segmentStart: 3, segmentEnd: 5},
]);
expect(mockDisplayer.appendSpy).toHaveBeenCalledOnceMoreWith([
[cue3, cue4],
]);
});
it('does not throw if called right before destroy', function(done) {
mockParseMedia.and.returnValue([1, 2, 3]);
textEngine.appendBuffer(dummyData, 0, 3).catch(fail).then(done);
textEngine.destroy();
});
});
describe('storeAndAppendClosedCaptions', function() {
it('appends closed captions with selected id', function() {
const caption = {
startPts: 0,
endPts: 100,
startTime: 0,
endTime: 1,
stream: 'CC1',
text: 'captions',
};
textEngine.setSelectedClosedCaptionId('CC1', 0);
textEngine.storeAndAppendClosedCaptions(
[caption], /* startTime */ 0, /* endTime */ 2, /* offset */ 0);
expect(mockDisplayer.appendSpy).toHaveBeenCalled();
});
it('does not append closed captions without selected id', function() {
const caption = {
startPts: 0,
endPts: 100,
startTime: 1,
endTime: 2,
stream: 'CC1',
text: 'caption2',
};
textEngine.setSelectedClosedCaptionId('CC3', 0);
textEngine.storeAndAppendClosedCaptions(
[caption], /* startTime */ 0, /* endTime */ 2, /* offset */ 0);
expect(mockDisplayer.appendSpy).not.toHaveBeenCalled();
});
it('stores closed captions', function() {
const caption0 = {
startPts: 0,
endPts: 100,
startTime: 0,
endTime: 1,
stream: 'CC1',
text: 'caption1',
};
const caption1 = {
startPts: 0,
endPts: 100,
startTime: 1,
endTime: 2,
stream: 'CC1',
text: 'caption2',
};
const caption2 = {
startPts: 0,
endPts: 100,
startTime: 1,
endTime: 2,
stream: 'CC3',
text: 'caption3',
};
textEngine.setSelectedClosedCaptionId('CC1', 0);
// Text Engine stores all the closed captions as a two layer map.
// {closed caption id -> {start and end time -> cues}}
textEngine.storeAndAppendClosedCaptions(
[caption0], /* startTime */ 0, /* endTime */ 1, /* offset */ 0);
expect(textEngine.getNumberOfClosedCaptionChannels()).toEqual(1);
expect(textEngine.getNumberOfClosedCaptionsInChannel('CC1')).toEqual(1);
textEngine.storeAndAppendClosedCaptions(
[caption1], /* startTime */ 1, /* endTime */ 2, /* offset */ 0);
// Caption1 has the same stream id with caption0, but different start and
// end time. The closed captions map should have 1 key CC1, and two values
// for two start and end times.
expect(textEngine.getNumberOfClosedCaptionChannels()).toEqual(1);
expect(textEngine.getNumberOfClosedCaptionsInChannel('CC1')).toEqual(2);
textEngine.storeAndAppendClosedCaptions(
[caption2], /* startTime */ 1, /* endTime */ 2, /* offset */ 0);
// Caption2 has a different stream id CC3, so the closed captions map
// should have two different keys, CC1 and CC3.
expect(textEngine.getNumberOfClosedCaptionChannels()).toEqual(2);
});
it('offsets closed captions to account for video offset', function() {
const caption = {
startPts: 0,
endPts: 100,
startTime: 0,
endTime: 1,
stream: 'CC1',
text: 'captions',
};
textEngine.setSelectedClosedCaptionId('CC1', 0);
textEngine.storeAndAppendClosedCaptions(
[caption], /* startTime */ 0, /* endTime */ 2, /* offset */ 1000);
expect(mockDisplayer.appendSpy).toHaveBeenCalledWith([
jasmine.objectContaining({
startTime: 1000,
endTime: 1001,
}),
]);
});
});
describe('remove', function() {
let cue1;
let cue2;
let cue3;
beforeEach(function() {
cue1 = createFakeCue(0, 1);
cue2 = createFakeCue(1, 2);
cue3 = createFakeCue(2, 3);
mockParseMedia.and.returnValue([cue1, cue2, cue3]);
});
it('works asynchronously', function(done) {
textEngine.appendBuffer(dummyData, 0, 3).then(function() {
let p = textEngine.remove(0, 1);
expect(mockDisplayer.removeSpy).not.toHaveBeenCalled();
return p;
}).catch(fail).then(done);
});
it('calls displayer.remove()', function(done) {
textEngine.remove(0, 1).then(function() {
expect(mockDisplayer.removeSpy).toHaveBeenCalledWith(0, 1);
}).catch(fail).then(done);
});
it('does not throw if called right before destroy', function(done) {
textEngine.remove(0, 1).catch(fail).then(done);
textEngine.destroy();
});
});
describe('setTimestampOffset', function() {
it('passes the offset to the parser', async () => {
mockParseMedia.and.callFake((data, time) => {
return [
createFakeCue(time.periodStart + 0,
time.periodStart + 1),
createFakeCue(time.periodStart + 2,
time.periodStart + 3),
];
});
await textEngine.appendBuffer(dummyData, 0, 3);
expect(mockParseMedia).toHaveBeenCalledOnceMoreWith([
new Uint8Array(dummyData),
{periodStart: 0, segmentStart: 0, segmentEnd: 3},
]);
expect(mockDisplayer.appendSpy).toHaveBeenCalledOnceMoreWith([
[
createFakeCue(0, 1),
createFakeCue(2, 3),
],
]);
textEngine.setTimestampOffset(4);
await textEngine.appendBuffer(dummyData, 4, 7);
expect(mockParseMedia).toHaveBeenCalledOnceMoreWith([
new Uint8Array(dummyData),
{periodStart: 4, segmentStart: 4, segmentEnd: 7},
]);
expect(mockDisplayer.appendSpy).toHaveBeenCalledOnceMoreWith([
[
createFakeCue(4, 5),
createFakeCue(6, 7),
],
]);
});
});
describe('bufferStart/bufferEnd', function() {
beforeEach(function() {
mockParseMedia.and.callFake(function() {
return [createFakeCue(0, 1), createFakeCue(1, 2), createFakeCue(2, 3)];
});
});
it('return null when there are no cues', function() {
expect(textEngine.bufferStart()).toBe(null);
expect(textEngine.bufferEnd()).toBe(null);
});
it('reflect newly-added cues', function(done) {
textEngine.appendBuffer(dummyData, 0, 3).then(function() {
expect(textEngine.bufferStart()).toBe(0);
expect(textEngine.bufferEnd()).toBe(3);
return textEngine.appendBuffer(dummyData, 3, 6);
}).then(function() {
expect(textEngine.bufferStart()).toBe(0);
expect(textEngine.bufferEnd()).toBe(6);
return textEngine.appendBuffer(dummyData, 6, 10);
}).then(function() {
expect(textEngine.bufferStart()).toBe(0);
expect(textEngine.bufferEnd()).toBe(10);
}).catch(fail).then(done);
});
it('reflect newly-removed cues', function(done) {
textEngine.appendBuffer(dummyData, 0, 3).then(function() {
return textEngine.appendBuffer(dummyData, 3, 6);
}).then(function() {
return textEngine.appendBuffer(dummyData, 6, 10);
}).then(function() {
expect(textEngine.bufferStart()).toBe(0);
expect(textEngine.bufferEnd()).toBe(10);
return textEngine.remove(0, 3);
}).then(function() {
expect(textEngine.bufferStart()).toBe(3);
expect(textEngine.bufferEnd()).toBe(10);
return textEngine.remove(8, 11);
}).then(function() {
expect(textEngine.bufferStart()).toBe(3);
expect(textEngine.bufferEnd()).toBe(8);
return textEngine.remove(11, 20);
}).then(function() {
expect(textEngine.bufferStart()).toBe(3);
expect(textEngine.bufferEnd()).toBe(8);
return textEngine.remove(0, Infinity);
}).then(function() {
expect(textEngine.bufferStart()).toBe(null);
expect(textEngine.bufferEnd()).toBe(null);
}).catch(fail).then(done);
});
it('does not use timestamp offset', async function() {
// The start and end times passed to appendBuffer are now absolute, so
// they already account for timestampOffset and period offset.
// See https://github.com/google/shaka-player/issues/1562
textEngine.setTimestampOffset(60);
await textEngine.appendBuffer(dummyData, 0, 3);
expect(textEngine.bufferStart()).toBe(0);
expect(textEngine.bufferEnd()).toBe(3);
await textEngine.appendBuffer(dummyData, 3, 6);
expect(textEngine.bufferStart()).toBe(0);
expect(textEngine.bufferEnd()).toBe(6);
});
});
describe('bufferedAheadOf', function() {
beforeEach(function() {
mockParseMedia.and.callFake(function() {
return [createFakeCue(0, 1), createFakeCue(1, 2), createFakeCue(2, 3)];
});
});
it('returns 0 when there are no cues', function() {
expect(textEngine.bufferedAheadOf(0)).toBe(0);
});
it('returns 0 if |t| is not buffered', function(done) {
textEngine.appendBuffer(dummyData, 3, 6).then(function() {
expect(textEngine.bufferedAheadOf(6.1)).toBe(0);
}).catch(fail).then(done);
});
it('ignores gaps in the content', function(done) {
textEngine.appendBuffer(dummyData, 3, 6).then(function() {
expect(textEngine.bufferedAheadOf(2)).toBe(3);
}).catch(fail).then(done);
});
it('returns the distance to the end if |t| is buffered', function(done) {
textEngine.appendBuffer(dummyData, 0, 3).then(function() {
expect(textEngine.bufferedAheadOf(0)).toBe(3);
expect(textEngine.bufferedAheadOf(1)).toBe(2);
expect(textEngine.bufferedAheadOf(2.5)).toBeCloseTo(0.5);
}).catch(fail).then(done);
});
it('does not use timestamp offset', async function() {
// The start and end times passed to appendBuffer are now absolute, so
// they already account for timestampOffset and period offset.
// See https://github.com/google/shaka-player/issues/1562
textEngine.setTimestampOffset(60);
await textEngine.appendBuffer(dummyData, 3, 6);
expect(textEngine.bufferedAheadOf(4)).toBe(2);
expect(textEngine.bufferedAheadOf(64)).toBe(0);
});
});
describe('setAppendWindow', function() {
beforeEach(function() {
mockParseMedia.and.callFake(function() {
return [createFakeCue(0, 1), createFakeCue(1, 2), createFakeCue(2, 3)];
});
});
it('limits appended cues', async () => {
textEngine.setAppendWindow(0, 1.9);
await textEngine.appendBuffer(dummyData, 0, 3);
expect(mockDisplayer.appendSpy).toHaveBeenCalledOnceMoreWith([
[
createFakeCue(0, 1),
createFakeCue(1, 2),
],
]);
textEngine.setAppendWindow(1, 2.1);
await textEngine.appendBuffer(dummyData, 0, 3);
expect(mockDisplayer.appendSpy).toHaveBeenCalledOnceMoreWith([
[
createFakeCue(1, 2),
createFakeCue(2, 3),
],
]);
});
it('limits bufferStart', function(done) {
textEngine.setAppendWindow(1, 9);
textEngine.appendBuffer(dummyData, 0, 3).then(function() {
expect(textEngine.bufferStart()).toBe(1);
return textEngine.remove(0, 9);
}).then(function() {
textEngine.setAppendWindow(2.1, 9);
return textEngine.appendBuffer(dummyData, 0, 3);
}).then(function() {
expect(textEngine.bufferStart()).toBe(2.1);
}).catch(fail).then(done);
});
it('limits bufferEnd', function(done) {
textEngine.setAppendWindow(0, 1.9);
textEngine.appendBuffer(dummyData, 0, 3).then(function() {
expect(textEngine.bufferEnd()).toBe(1.9);
textEngine.setAppendWindow(0, 2.1);
return textEngine.appendBuffer(dummyData, 0, 3);
}).then(function() {
expect(textEngine.bufferEnd()).toBe(2.1);
textEngine.setAppendWindow(0, 4.1);
return textEngine.appendBuffer(dummyData, 0, 3);
}).then(function() {
expect(textEngine.bufferEnd()).toBe(3);
}).catch(fail).then(done);
});
});
function createFakeCue(startTime, endTime) {
return {startTime: startTime, endTime: endTime};
}
});