7e55d2e1 by Matthew Neil Committed by Jon-Carlos Rivera

Ad cues (#804)

* Converted the ad-related cues generated from `cue-out` and `cue-in` into a single cue spanning the period between ad-break start and ad-break end
1 parent c8da7b50
......@@ -218,11 +218,8 @@ for more info.
* can be used as an initialization option
When the `useCueTags` property is set to `true,` a text track is created with
label 'hls-segment-metadata' and kind 'metadata'. The track is then added to
`player.textTracks()`. Whenever a segment associated with a cue tag is playing,
the cue tags will be listed as a properties inside of a stringified JSON object
under its active cue's `text` property. The properties that are currently
supported are cueOut, cueOutCont, and cueIn. Changes in active cue may be
label 'ad-cues' and kind 'metadata'. The track is then added to
`player.textTracks()`. Changes in active cue may be
tracked by following the Video.js cue points API for text tracks. For example:
```javascript
......@@ -230,7 +227,7 @@ let textTracks = player.textTracks();
letcuesTrack;
for (let i = 0; i < textTracks.length; i++) {
  if (textTracks[i].label === 'hls-segment-metadata') {
  if (textTracks[i].label === 'ad-cues') {
    cuesTrack = textTracks[i];
  }
}
......@@ -240,12 +237,9 @@ cuesTrack.addEventListener('cuechange', function() {
  for (let i = 0; i < activeCues.length; i++) {
let activeCue = activeCues[i];
let cueData = JSON.parse(activeCue.text);
    console.log('Cue runs from ' + activeCue.startTime +
' to ' + activeCue.endTime +
' with cue tag contents ' +
(cueData.cueOut || cueData.cueOutCont || cueData.cueIn));
' to ' + activeCue.endTime);
  }
});
```
......
/**
* @file ad-cue-tags.js
*/
import window from 'global/window';
/**
* Searches for an ad cue that overlaps with the given mediaTime
*/
const findAdCue = function(track, mediaTime) {
let cues = track.cues;
for (let i = 0; i < cues.length; i++) {
let cue = cues[i];
if (mediaTime >= cue.adStartTime && mediaTime <= cue.adEndTime) {
return cue;
}
}
return null;
};
const updateAdCues = function(media, track, offset = 0) {
if (!media.segments) {
return;
}
let mediaTime = offset;
let cue;
for (let i = 0; i < media.segments.length; i++) {
let segment = media.segments[i];
if (!cue) {
// Since the cues will span for at least the segment duration, adding a fudge
// factor of half segment duration will prevent duplicate cues from being
// created when timing info is not exact (e.g. cue start time initialized
// at 10.006677, but next call mediaTime is 10.003332 )
cue = findAdCue(track, mediaTime + (segment.duration / 2));
}
if (cue) {
if ('cueIn' in segment) {
// Found a CUE-IN so end the cue
cue.endTime = mediaTime;
cue.adEndTime = mediaTime;
mediaTime += segment.duration;
cue = null;
continue;
}
if (mediaTime < cue.endTime) {
// Already processed this mediaTime for this cue
mediaTime += segment.duration;
continue;
}
// otherwise extend cue until a CUE-IN is found
cue.endTime += segment.duration;
} else {
if ('cueOut' in segment) {
cue = new window.VTTCue(mediaTime,
mediaTime + segment.duration,
segment.cueOut);
cue.adStartTime = mediaTime;
// Assumes tag format to be
// #EXT-X-CUE-OUT:30
cue.adEndTime = mediaTime + parseFloat(segment.cueOut);
track.addCue(cue);
}
if ('cueOutCont' in segment) {
// Entered into the middle of an ad cue
let adOffset;
let adTotal;
// Assumes tag formate to be
// #EXT-X-CUE-OUT-CONT:10/30
[adOffset, adTotal] = segment.cueOutCont.split('/').map(parseFloat);
cue = new window.VTTCue(mediaTime,
mediaTime + segment.duration,
'');
cue.adStartTime = mediaTime - adOffset;
cue.adEndTime = cue.adStartTime + adTotal;
track.addCue(cue);
}
}
mediaTime += segment.duration;
}
};
export default {
updateAdCues,
findAdCue
};
......@@ -6,7 +6,7 @@ import SegmentLoader from './segment-loader';
import Ranges from './ranges';
import videojs from 'video.js';
import HlsAudioTrack from './hls-audio-track';
import window from 'global/window';
import AdCueTags from './ad-cue-tags';
// 5 minute blacklist
const BLACKLIST_DURATION = 5 * 60 * 1000;
......@@ -62,7 +62,8 @@ export default class MasterPlaylistController extends videojs.EventTarget {
this.mode_ = mode;
this.useCueTags_ = useCueTags;
if (this.useCueTags_) {
this.cueTagsTrack_ = this.tech_.addTextTrack('metadata', 'hls-segment-metadata');
this.cueTagsTrack_ = this.tech_.addTextTrack('metadata',
'ad-cues');
this.cueTagsTrack_.inBandMetadataTrackDispatchType = '';
this.tech_.textTracks().addTrack_(this.cueTagsTrack_);
}
......@@ -133,7 +134,10 @@ export default class MasterPlaylistController extends videojs.EventTarget {
return;
}
this.updateCues_(updatedPlaylist);
if (this.useCueTags_) {
this.updateAdCues_(updatedPlaylist,
this.masterPlaylistLoader_.expired_);
}
// TODO: Create a new event on the PlaylistLoader that signals
// that the segments have changed in some way and use that to
......@@ -831,43 +835,7 @@ export default class MasterPlaylistController extends videojs.EventTarget {
});
}
updateCues_(media) {
if (!this.useCueTags_ || !media.segments) {
return;
}
while (this.cueTagsTrack_.cues.length) {
this.cueTagsTrack_.removeCue(this.cueTagsTrack_.cues[0]);
}
let mediaTime = 0;
for (let i = 0; i < media.segments.length; i++) {
let segment = media.segments[i];
if ('cueOut' in segment || 'cueOutCont' in segment || 'cueIn' in segment) {
let cueJson = {};
if ('cueOut' in segment) {
cueJson.cueOut = segment.cueOut;
}
if ('cueOutCont' in segment) {
cueJson.cueOutCont = segment.cueOutCont;
}
if ('cueIn' in segment) {
cueJson.cueIn = segment.cueIn;
}
// Use a short duration for the cue point, as it should trigger for a segment
// transition (in this case, defined as the beginning of the segment that the tag
// precedes), but keep it for a minimum of 0.5 seconds to remain usable (won't
// lose it as an active cue by the time a user retrieves the active cues).
this.cueTagsTrack_.addCue(new window.VTTCue(mediaTime,
mediaTime + 0.5,
JSON.stringify(cueJson)));
}
mediaTime += segment.duration;
}
updateAdCues_(media, offset = 0) {
AdCueTags.updateAdCues(media, this.cueTagsTrack_, offset);
}
}
......
import QUnit from 'qunit';
import AdCueTags from '../src/ad-cue-tags';
import window from 'global/window';
QUnit.module('AdCueTags', {
beforeEach() {
this.track = {
cues: [],
addCue(cue) {
this.cues.push(cue);
},
clearTrack() {
this.cues = [];
}
};
}
});
QUnit.test('update tag cues', function() {
let testCue = new window.VTTCue(0, 10, 'test');
this.track.addCue(testCue);
AdCueTags.updateAdCues({}, this.track);
QUnit.equal(this.track.cues.length,
1,
'does not change cues if media does not have segment property');
QUnit.equal(this.track.cues[0],
testCue,
'does not change cues if media does not have segment property');
AdCueTags.updateAdCues({
segments: []
}, this.track);
QUnit.equal(this.track.cues.length,
1,
'does not remove cues even if no segments in playlist');
this.track.clearTrack();
AdCueTags.updateAdCues({
segments: [{
duration: 5.1,
cueOut: '11.5'
}, {
duration: 6.4,
cueOutCont: '5.1/11.5'
}, {
duration: 6,
cueIn: ''
}]
}, this.track, 10);
QUnit.equal(this.track.cues.length, 1, 'adds a single cue for entire ad');
testCue = this.track.cues[0];
QUnit.equal(testCue.startTime, 10, 'cue starts at 10');
QUnit.equal(testCue.endTime, 21.5, 'cue ends at start time plus duration');
this.track.clearTrack();
AdCueTags.updateAdCues({
segments: [{
duration: 10,
cueOutCont: '10/30'
}, {
duration: 10,
cueOutCont: '20/30'
}, {
duration: 10,
cueIn: ''
}]
}, this.track);
QUnit.equal(this.track.cues.length, 1,
'adds a single cue for entire ad when entering mid cue-out-cont');
testCue = this.track.cues[0];
QUnit.equal(testCue.startTime, 0, 'cue starts at 0');
QUnit.equal(testCue.endTime, 20, 'cue ends at start time plus duration');
QUnit.equal(testCue.adStartTime, -10, 'cue ad starts at -10');
QUnit.equal(testCue.adEndTime, 20, 'cue ad ends at 20');
});
QUnit.test('update incomplete cue in live playlist situation', function() {
AdCueTags.updateAdCues({
segments: [
{
duration: 10,
cueOut: '30'
},
{
duration: 10,
cueOutCont: '10/30'
}
]
}, this.track, 10);
QUnit.equal(this.track.cues.length, 1, 'adds a single cue for new ad');
let testCue = this.track.cues[0];
QUnit.equal(testCue.startTime, 10, 'cue starts at 10');
QUnit.equal(testCue.endTime, 30, 'cue ends at start time plus segment durations');
QUnit.equal(testCue.adStartTime, 10, 'cue ad starts at 10');
QUnit.equal(testCue.adEndTime, 40, 'cue ad ends at 40');
AdCueTags.updateAdCues({
segments: [
{
duration: 10,
cueOutCont: '10/30'
},
{
duration: 10,
cueOutCont: '20/30'
}
]
}, this.track, 20);
QUnit.equal(this.track.cues.length, 1, 'did not remove cue or add a new one');
QUnit.equal(testCue.startTime, 10, 'cue still starts at 10');
QUnit.equal(testCue.endTime, 40, 'cue end updated to include next segment duration');
QUnit.equal(testCue.adStartTime, 10, 'cue ad still starts at 10');
QUnit.equal(testCue.adEndTime, 40, 'cue ad still ends at 40');
AdCueTags.updateAdCues({
segments: [
{
duration: 10,
cueOutCont: '20/30'
},
{
duration: 10,
cueIn: ''
}
]
}, this.track, 30);
QUnit.equal(this.track.cues.length, 1, 'did not remove cue or add a new one');
QUnit.equal(testCue.startTime, 10, 'cue still starts at 10');
QUnit.equal(testCue.endTime, 40, 'cue end still 40');
QUnit.equal(testCue.adStartTime, 10, 'cue ad still starts at 10');
QUnit.equal(testCue.adEndTime, 40, 'cue ad still ends at 40');
});
QUnit.test('adjust cue end time in event of early CUE-IN', function() {
AdCueTags.updateAdCues({
segments: [
{
duration: 10,
cueOut: '30'
},
{
duration: 10,
cueOutCont: '10/30'
},
{
duration: 10,
cueOutCont: '20/30'
}
]
}, this.track, 10);
QUnit.equal(this.track.cues.length, 1, 'adds a single cue for new ad');
let testCue = this.track.cues[0];
QUnit.equal(testCue.startTime, 10, 'cue starts at 10');
QUnit.equal(testCue.endTime, 40, 'cue ends at start time plus segment durations');
QUnit.equal(testCue.adStartTime, 10, 'cue ad starts at 10');
QUnit.equal(testCue.adEndTime, 40, 'cue ad ends at 40');
AdCueTags.updateAdCues({
segments: [
{
duration: 10,
cueOutCont: '10/30'
},
{
duration: 10,
cueIn: ''
},
{
duration: 10
}
]
}, this.track, 20);
QUnit.equal(this.track.cues.length, 1, 'did not remove cue or add a new one');
QUnit.equal(testCue.startTime, 10, 'cue still starts at 10');
QUnit.equal(testCue.endTime, 30, 'cue end updated to 30');
QUnit.equal(testCue.adStartTime, 10, 'cue ad still starts at 10');
QUnit.equal(testCue.adEndTime, 30,
'cue ad end updated to 30 to account for early cueIn');
});
QUnit.test('correctly handle multiple ad cues', function() {
AdCueTags.updateAdCues({
segments: [
{
duration: 10
},
{
duration: 10
},
{
duration: 10
},
{
duration: 10,
cueOut: '30'
},
{
duration: 10,
cueOutCont: '10/30'
},
{
duration: 10,
cueOutCont: '20/30'
},
{
duration: 10,
cueIn: ''
},
{
duration: 10
},
{
duration: 10
},
{
duration: 10
},
{
duration: 10,
cueOut: '20'
},
{
duration: 10,
cueOutCont: '10/20'
},
{
duration: 10,
cueIn: ''
},
{
duration: 10
}
]
}, this.track);
QUnit.equal(this.track.cues.length, 2, 'correctly created 2 cues for the ads');
QUnit.equal(this.track.cues[0].startTime, 30, 'cue created at correct start time');
QUnit.equal(this.track.cues[0].endTime, 60, 'cue has correct end time');
QUnit.equal(this.track.cues[0].adStartTime, 30, 'cue has correct ad start time');
QUnit.equal(this.track.cues[0].adEndTime, 60, 'cue has correct ad end time');
QUnit.equal(this.track.cues[1].startTime, 100, 'cue created at correct start time');
QUnit.equal(this.track.cues[1].endTime, 120, 'cue has correct end time');
QUnit.equal(this.track.cues[1].adStartTime, 100, 'cue has correct ad start time');
QUnit.equal(this.track.cues[1].adEndTime, 120, 'cue has correct ad end time');
});
QUnit.test('findAdCue returns correct cue', function() {
this.track.cues = [
{
adStartTime: 0,
adEndTime: 30
},
{
adStartTime: 45,
adEndTime: 55
},
{
adStartTime: 100,
adEndTime: 120
}
];
let cue;
cue = AdCueTags.findAdCue(this.track, 15);
QUnit.equal(cue.adStartTime, 0, 'returned correct cue');
cue = AdCueTags.findAdCue(this.track, 40);
QUnit.equal(cue, null, 'cue not found, returned null');
cue = AdCueTags.findAdCue(this.track, 120);
QUnit.equal(cue.adStartTime, 100, 'returned correct cue');
cue = AdCueTags.findAdCue(this.track, 45);
QUnit.equal(cue.adStartTime, 45, 'returned correct cue');
});
......@@ -13,7 +13,6 @@ import MasterPlaylistController from '../src/master-playlist-controller';
import { Hls } from '../src/videojs-contrib-hls';
/* eslint-enable no-unused-vars */
import Playlist from '../src/playlist';
import window from 'global/window';
QUnit.module('MasterPlaylistController', {
beforeEach() {
......@@ -620,9 +619,22 @@ function() {
});
QUnit.test('calls to update cues on new media', function() {
let origHlsOptions = videojs.options.hls;
videojs.options.hls = {
useCueTags: true
};
this.player = createPlayer();
this.player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_;
let callCount = 0;
this.masterPlaylistController.updateCues_ = (media) => callCount++;
this.masterPlaylistController.updateAdCues_ = (media) => callCount++;
// master
standardXHRResponse(this.requests.shift());
......@@ -637,19 +649,24 @@ QUnit.test('calls to update cues on new media', function() {
this.masterPlaylistController.masterPlaylistLoader_.trigger('loadedplaylist');
QUnit.equal(callCount, 2, 'calls to update cues on subsequent media');
videojs.options.hls = origHlsOptions;
});
QUnit.test('calls to update cues on media when no master', function() {
this.requests.length = 0;
this.player.src({
src: 'manifest/media.m3u8',
type: 'application/vnd.apple.mpegurl'
});
this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_;
this.masterPlaylistController.useCueTags_ = true;
let callCount = 0;
this.masterPlaylistController.updateCues_ = (media) => callCount++;
this.masterPlaylistController.updateAdCues_ = (media) => callCount++;
// media
standardXHRResponse(this.requests.shift());
......@@ -662,21 +679,6 @@ QUnit.test('calls to update cues on media when no master', function() {
});
QUnit.test('respects useCueTags option', function() {
this.masterPlaylistController.updateCues_({
segments: [{
duration: 10,
tags: ['test']
}]
});
QUnit.ok(!this.masterPlaylistController.cueTagsTrack_,
'does not create cueTagsTrack_ if useCueTags is falsy');
QUnit.equal(this.player.textTracks().length,
0,
'does not create a text track if useCueTags is falsy');
this.player.dispose();
let origHlsOptions = videojs.options.hls;
videojs.options.hls = {
......@@ -693,109 +695,10 @@ QUnit.test('respects useCueTags option', function() {
QUnit.ok(this.masterPlaylistController.cueTagsTrack_,
'creates cueTagsTrack_ if useCueTags is truthy');
QUnit.equal(this.masterPlaylistController.cueTagsTrack_.label,
'hls-segment-metadata',
'cueTagsTrack_ has label of hls-segment-metadata');
'ad-cues',
'cueTagsTrack_ has label of ad-cues');
QUnit.equal(this.player.textTracks()[0], this.masterPlaylistController.cueTagsTrack_,
'adds cueTagsTrack as a text track if useCueTags is truthy');
this.masterPlaylistController.updateCues_({
segments: [{
duration: 10,
cueOut: 'test'
}]
});
let cue = this.masterPlaylistController.cueTagsTrack_.cues[0];
QUnit.equal(cue.startTime,
0,
'adds cue with correct start time if useCueTags is truthy');
QUnit.equal(cue.endTime,
0.5,
'adds cue with correct end time if useCueTags is truthy');
QUnit.equal(cue.text,
JSON.stringify({ cueOut: 'test' }),
'adds cue with correct text if useCueTags is truthy');
videojs.options.hls = origHlsOptions;
});
QUnit.test('update tag cues', function() {
let origHlsOptions = videojs.options.hls;
videojs.options.hls = {
useCueTags: true
};
this.player = createPlayer();
this.player.src({
src: 'manifest/master.m3u8',
type: 'application/vnd.apple.mpegurl'
});
this.masterPlaylistController = this.player.tech_.hls.masterPlaylistController_;
let cueTagsTrack = this.masterPlaylistController.cueTagsTrack_;
let testCue = new window.VTTCue(0, 10, 'test');
cueTagsTrack.addCue(testCue);
this.masterPlaylistController.updateCues_({});
QUnit.equal(cueTagsTrack.cues.length,
1,
'does not change cues if media does not have segment property');
QUnit.equal(cueTagsTrack.cues[0],
testCue,
'does not change cues if media does not have segment property');
this.masterPlaylistController.updateCues_({
segments: []
});
QUnit.equal(cueTagsTrack.cues.length,
0,
'removes cues even if no segments in playlist');
this.masterPlaylistController.updateCues_({
segments: [{
duration: 5.1,
cueOut: '11.5'
}, {
duration: 6.4,
cueOutCont: '5.1/11.5'
}, {
duration: 6,
cueIn: ''
}]
});
QUnit.equal(cueTagsTrack.cues.length, 3, 'adds a cue for each segment');
QUnit.equal(cueTagsTrack.cues[0].startTime, 0, 'cue starts at 0');
QUnit.equal(cueTagsTrack.cues[0].endTime, 0.5, 'cue ends at start time plus duration');
QUnit.equal(JSON.parse(cueTagsTrack.cues[0].text).cueOut, '11.5', 'cueOut matches');
QUnit.ok(!('cueOutCont' in JSON.parse(cueTagsTrack.cues[0].text)),
'cueOutCont not in cue');
QUnit.ok(!('cueIn' in JSON.parse(cueTagsTrack.cues[0].text)), 'cueIn not in cue');
QUnit.equal(cueTagsTrack.cues[1].startTime, 5.1, 'cue starts at 5.1');
QUnit.equal(cueTagsTrack.cues[1].endTime, 5.6, 'cue ends at start time plus duration');
QUnit.equal(JSON.parse(cueTagsTrack.cues[1].text).cueOutCont,
'5.1/11.5',
'cueOutCont matches');
QUnit.ok(!('cueOut' in JSON.parse(cueTagsTrack.cues[1].text)), 'cueOut not in cue');
QUnit.ok(!('cueIn' in JSON.parse(cueTagsTrack.cues[1].text)), 'cueIn not in cue');
QUnit.equal(cueTagsTrack.cues[2].startTime, 11.5, 'cue starts at 11.5');
QUnit.equal(cueTagsTrack.cues[2].endTime, 12, 'cue ends at start time plus duration');
QUnit.equal(JSON.parse(cueTagsTrack.cues[2].text).cueIn, '', 'cueIn matches');
QUnit.ok(!('cueOut' in JSON.parse(cueTagsTrack.cues[2].text)), 'cueOut not in cue');
QUnit.ok(!('cueOutCont' in JSON.parse(cueTagsTrack.cues[2].text)),
'cueOutCont not in cue');
this.masterPlaylistController.updateCues_({
segments: []
});
QUnit.equal(cueTagsTrack.cues.length, 0, 'removes old cues on update');
videojs.options.hls = origHlsOptions;
});
......