43a47b68 by David LaPalomento

Merge pull request #221 from videojs/feature/in-band-metadata

In-Band Metadata
2 parents e686ddde 0e85b0f0
...@@ -24,12 +24,13 @@ module.exports = function(grunt) { ...@@ -24,12 +24,13 @@ module.exports = function(grunt) {
24 dist: { 24 dist: {
25 nonull: true, 25 nonull: true,
26 src: ['src/videojs-hls.js', 26 src: ['src/videojs-hls.js',
27 'src/stream.js',
27 'src/flv-tag.js', 28 'src/flv-tag.js',
28 'src/exp-golomb.js', 29 'src/exp-golomb.js',
29 'src/h264-stream.js', 30 'src/h264-stream.js',
30 'src/aac-stream.js', 31 'src/aac-stream.js',
32 'src/metadata-stream.js',
31 'src/segment-parser.js', 33 'src/segment-parser.js',
32 'src/stream.js',
33 'src/m3u8/m3u8-parser.js', 34 'src/m3u8/m3u8-parser.js',
34 'src/xhr.js', 35 'src/xhr.js',
35 'src/playlist-loader.js', 36 'src/playlist-loader.js',
......
...@@ -155,6 +155,20 @@ the actual rendering quality change does not occur simultaneously with ...@@ -155,6 +155,20 @@ the actual rendering quality change does not occur simultaneously with
155 this event; a new segment must be requested and the existing buffer 155 this event; a new segment must be requested and the existing buffer
156 depleted first. 156 depleted first.
157 157
158 ### In-Band Metadata
159 The HLS tech supports [timed
160 metadata](https://developer.apple.com/library/ios/#documentation/AudioVideo/Conceptual/HTTP_Live_Streaming_Metadata_Spec/Introduction/Introduction.html)
161 embedded as [ID3 tags](http://id3.org/id3v2.3.0). When a stream is
162 encountered with embedded metadata, an [in-band metadata text
163 track](https://html.spec.whatwg.org/multipage/embedded-content.html#text-track-in-band-metadata-track-dispatch-type)
164 will automatically be created and populated with cues as they are
165 encountered in the stream. Only UTF-8 encoded
166 [TXXX](http://id3.org/id3v2.3.0#User_defined_text_information_frame)
167 and [WXXX](http://id3.org/id3v2.3.0#User_defined_URL_link_frame) ID3
168 frames are currently mapped to cue points. There are lots of guides
169 and references to using text tracks [around the
170 web](http://www.html5rocks.com/en/tutorials/track/basics/).
171
158 ### Testing 172 ### Testing
159 173
160 For testing, you can either run `npm test` or use `grunt` directly. 174 For testing, you can either run `npm test` or use `grunt` directly.
......
...@@ -18,13 +18,14 @@ ...@@ -18,13 +18,14 @@
18 <!-- segment handling --> 18 <!-- segment handling -->
19 <script src="src/xhr.js"></script> 19 <script src="src/xhr.js"></script>
20 <script src="src/flv-tag.js"></script> 20 <script src="src/flv-tag.js"></script>
21 <script src="src/stream.js"></script>
21 <script src="src/exp-golomb.js"></script> 22 <script src="src/exp-golomb.js"></script>
22 <script src="src/h264-stream.js"></script> 23 <script src="src/h264-stream.js"></script>
23 <script src="src/aac-stream.js"></script> 24 <script src="src/aac-stream.js"></script>
25 <script src="src/metadata-stream.js"></script>
24 <script src="src/segment-parser.js"></script> 26 <script src="src/segment-parser.js"></script>
25 27
26 <!-- m3u8 handling --> 28 <!-- m3u8 handling -->
27 <script src="src/stream.js"></script>
28 <script src="src/m3u8/m3u8-parser.js"></script> 29 <script src="src/m3u8/m3u8-parser.js"></script>
29 <script src="src/playlist-loader.js"></script> 30 <script src="src/playlist-loader.js"></script>
30 31
......
1 /**
2 * Accepts program elementary stream (PES) data events and parses out
3 * ID3 metadata from them, if present.
4 * @see http://id3.org/id3v2.3.0
5 */
6 (function(window, videojs, undefined) {
7 'use strict';
8 var
9 parseString = function(bytes, start, end) {
10 var i, result = '';
11 for (i = start; i < end; i++) {
12 result += '%' + ('00' + bytes[i].toString(16)).slice(-2);
13 }
14 return window.decodeURIComponent(result);
15 },
16 tagParsers = {
17 'TXXX': function(tag) {
18 var i;
19 if (tag.data[0] !== 3) {
20 // ignore frames with unrecognized character encodings
21 return;
22 }
23
24 for (i = 1; i < tag.data.length; i++) {
25 if (tag.data[i] === 0) {
26 // parse the text fields
27 tag.description = parseString(tag.data, 1, i);
28 tag.value = parseString(tag.data, i + 1, tag.data.length);
29 break;
30 }
31 }
32 },
33 'WXXX': function(tag) {
34 var i;
35 if (tag.data[0] !== 3) {
36 // ignore frames with unrecognized character encodings
37 return;
38 }
39
40 for (i = 1; i < tag.data.length; i++) {
41 if (tag.data[i] === 0) {
42 // parse the description and URL fields
43 tag.description = parseString(tag.data, 1, i);
44 tag.url = parseString(tag.data, i + 1, tag.data.length);
45 break;
46 }
47 }
48 }
49 },
50 MetadataStream;
51
52 MetadataStream = function(options) {
53 var settings = {
54 debug: !!(options && options.debug),
55
56 // the bytes of the program-level descriptor field in MP2T
57 // see ISO/IEC 13818-1:2013 (E), section 2.6 "Program and
58 // program element descriptors"
59 descriptor: options && options.descriptor
60 }, i;
61 MetadataStream.prototype.init.call(this);
62
63 // calculate the text track in-band metadata track dispatch type
64 // https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track
65 this.dispatchType = videojs.Hls.SegmentParser.STREAM_TYPES.metadata.toString(16);
66 if (settings.descriptor) {
67 for (i = 0; i < settings.descriptor.length; i++) {
68 this.dispatchType += ('00' + settings.descriptor[i].toString(16)).slice(-2);
69 }
70 }
71
72 this.push = function(chunk) {
73 var tagSize, frameStart, frameSize, frame;
74
75 // ignore events that don't look like ID3 data
76 if (chunk.data.length < 10 ||
77 chunk.data[0] !== 'I'.charCodeAt(0) ||
78 chunk.data[1] !== 'D'.charCodeAt(0) ||
79 chunk.data[2] !== '3'.charCodeAt(0)) {
80 if (settings.debug) {
81 videojs.log('Skipping unrecognized metadata stream');
82 }
83 return;
84 }
85
86 // find the start of the first frame and the end of the tag
87 tagSize = chunk.data.byteLength;
88 frameStart = 10;
89 if (chunk.data[5] & 0x40) {
90 // advance the frame start past the extended header
91 frameStart += 4; // header size field
92 frameStart += (chunk.data[10] << 24) |
93 (chunk.data[11] << 16) |
94 (chunk.data[12] << 8) |
95 (chunk.data[13]);
96
97 // clip any padding off the end
98 tagSize -= (chunk.data[16] << 24) |
99 (chunk.data[17] << 16) |
100 (chunk.data[18] << 8) |
101 (chunk.data[19]);
102 }
103
104 // adjust the PTS values to align with the video and audio
105 // streams
106 if (this.timestampOffset) {
107 chunk.pts -= this.timestampOffset;
108 chunk.dts -= this.timestampOffset;
109 }
110
111 // parse one or more ID3 frames
112 // http://id3.org/id3v2.3.0#ID3v2_frame_overview
113 chunk.frames = [];
114 do {
115 // determine the number of bytes in this frame
116 frameSize = (chunk.data[frameStart + 4] << 24) |
117 (chunk.data[frameStart + 5] << 16) |
118 (chunk.data[frameStart + 6] << 8) |
119 (chunk.data[frameStart + 7]);
120 if (frameSize < 1) {
121 return videojs.log('Malformed ID3 frame encountered. Skipping metadata parsing.');
122 }
123
124 frame = {
125 id: String.fromCharCode(chunk.data[frameStart]) +
126 String.fromCharCode(chunk.data[frameStart + 1]) +
127 String.fromCharCode(chunk.data[frameStart + 2]) +
128 String.fromCharCode(chunk.data[frameStart + 3]),
129 data: chunk.data.subarray(frameStart + 10, frameStart + frameSize + 10)
130 };
131 if (tagParsers[frame.id]) {
132 tagParsers[frame.id](frame);
133 }
134 chunk.frames.push(frame);
135
136 frameStart += 10; // advance past the frame header
137 frameStart += frameSize; // advance past the frame body
138 } while (frameStart < tagSize);
139 this.trigger('data', chunk);
140 };
141 };
142 MetadataStream.prototype = new videojs.Hls.Stream();
143
144 videojs.Hls.MetadataStream = MetadataStream;
145 })(window, window.videojs);
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
4 FlvTag = videojs.Hls.FlvTag, 4 FlvTag = videojs.Hls.FlvTag,
5 H264Stream = videojs.Hls.H264Stream, 5 H264Stream = videojs.Hls.H264Stream,
6 AacStream = videojs.Hls.AacStream, 6 AacStream = videojs.Hls.AacStream,
7 MetadataStream = videojs.Hls.MetadataStream,
7 MP2T_PACKET_LENGTH, 8 MP2T_PACKET_LENGTH,
8 STREAM_TYPES; 9 STREAM_TYPES;
9 10
...@@ -27,6 +28,9 @@ ...@@ -27,6 +28,9 @@
27 programMapTable: {} 28 programMapTable: {}
28 }; 29 };
29 30
31 // allow in-band metadata to be observed
32 self.metadataStream = new MetadataStream();
33
30 // For information on the FLV format, see 34 // For information on the FLV format, see
31 // http://download.macromedia.com/f4v/video_file_format_spec_v10_1.pdf. 35 // http://download.macromedia.com/f4v/video_file_format_spec_v10_1.pdf.
32 // Technically, this function returns the header and a metadata FLV tag 36 // Technically, this function returns the header and a metadata FLV tag
...@@ -287,7 +291,8 @@ ...@@ -287,7 +291,8 @@
287 self.stream.pmtPid = (data[offset + 2] & 0x1F) << 8 | data[offset + 3]; 291 self.stream.pmtPid = (data[offset + 2] & 0x1F) << 8 | data[offset + 3];
288 } 292 }
289 } else if (pid === self.stream.programMapTable[STREAM_TYPES.h264] || 293 } else if (pid === self.stream.programMapTable[STREAM_TYPES.h264] ||
290 pid === self.stream.programMapTable[STREAM_TYPES.adts]) { 294 pid === self.stream.programMapTable[STREAM_TYPES.adts] ||
295 pid === self.stream.programMapTable[STREAM_TYPES.metadata]) {
291 if (pusi) { 296 if (pusi) {
292 // comment out for speed 297 // comment out for speed
293 if (0x00 !== data[offset + 0] || 0x00 !== data[offset + 1] || 0x01 !== data[offset + 2]) { 298 if (0x00 !== data[offset + 0] || 0x00 !== data[offset + 1] || 0x01 !== data[offset + 2]) {
...@@ -328,9 +333,16 @@ ...@@ -328,9 +333,16 @@
328 dts /= 45; 333 dts /= 45;
329 } 334 }
330 } 335 }
336
331 // Skip past "optional" portion of PTS header 337 // Skip past "optional" portion of PTS header
332 offset += pesHeaderLength; 338 offset += pesHeaderLength;
333 339
340 // align the metadata stream PTS values with the start of
341 // the other elementary streams
342 if (!self.metadataStream.timestampOffset) {
343 self.metadataStream.timestampOffset = pts;
344 }
345
334 if (pid === self.stream.programMapTable[STREAM_TYPES.h264]) { 346 if (pid === self.stream.programMapTable[STREAM_TYPES.h264]) {
335 h264Stream.setNextTimeStamp(pts, 347 h264Stream.setNextTimeStamp(pts,
336 dts, 348 dts,
...@@ -339,6 +351,12 @@ ...@@ -339,6 +351,12 @@
339 aacStream.setNextTimeStamp(pts, 351 aacStream.setNextTimeStamp(pts,
340 pesPacketSize, 352 pesPacketSize,
341 dataAlignmentIndicator); 353 dataAlignmentIndicator);
354 } else {
355 self.metadataStream.push({
356 pts: pts,
357 dts: dts,
358 data: data.subarray(offset)
359 });
342 } 360 }
343 } 361 }
344 362
...@@ -383,23 +401,26 @@ ...@@ -383,23 +401,26 @@
383 // the PID for this entry 401 // the PID for this entry
384 elementaryPID = (data[offset + 1] & 0x1F) << 8 | data[offset + 2]; 402 elementaryPID = (data[offset + 1] & 0x1F) << 8 | data[offset + 2];
385 403
386 if (streamType === STREAM_TYPES.h264) { 404 if (streamType === STREAM_TYPES.h264 &&
387 if (self.stream.programMapTable[streamType] && 405 self.stream.programMapTable[streamType] &&
388 self.stream.programMapTable[streamType] !== elementaryPID) { 406 self.stream.programMapTable[streamType] !== elementaryPID) {
389 throw new Error("Program has more than 1 video stream"); 407 throw new Error("Program has more than 1 video stream");
390 } 408 } else if (streamType === STREAM_TYPES.adts &&
391 self.stream.programMapTable[streamType] = elementaryPID; 409 self.stream.programMapTable[streamType] &&
392 } else if (streamType === STREAM_TYPES.adts) {
393 if (self.stream.programMapTable[streamType] &&
394 self.stream.programMapTable[streamType] !== elementaryPID) { 410 self.stream.programMapTable[streamType] !== elementaryPID) {
395 throw new Error("Program has more than 1 audio Stream"); 411 throw new Error("Program has more than 1 audio Stream");
396 } 412 }
413 // add the stream type entry to the map
397 self.stream.programMapTable[streamType] = elementaryPID; 414 self.stream.programMapTable[streamType] = elementaryPID;
398 } 415
399 // TODO add support for MP3 audio 416 // TODO add support for MP3 audio
400 417
401 // the length of the entry descriptor 418 // the length of the entry descriptor
402 ESInfolength = (data[offset + 3] & 0x0F) << 8 | data[offset + 4]; 419 ESInfolength = (data[offset + 3] & 0x0F) << 8 | data[offset + 4];
420 // capture the stream descriptor for metadata streams
421 if (streamType === STREAM_TYPES.metadata) {
422 self.metadataStream.descriptor = new Uint8Array(data.subarray(offset + 5, offset + 5 + ESInfolength));
423 }
403 // move to the first byte after the end of this entry 424 // move to the first byte after the end of this entry
404 offset += 5 + ESInfolength; 425 offset += 5 + ESInfolength;
405 pmtSectionLength -= 5 + ESInfolength; 426 pmtSectionLength -= 5 + ESInfolength;
...@@ -435,7 +456,8 @@ ...@@ -435,7 +456,8 @@
435 videojs.Hls.SegmentParser.MP2T_PACKET_LENGTH = MP2T_PACKET_LENGTH = 188; 456 videojs.Hls.SegmentParser.MP2T_PACKET_LENGTH = MP2T_PACKET_LENGTH = 188;
436 videojs.Hls.SegmentParser.STREAM_TYPES = STREAM_TYPES = { 457 videojs.Hls.SegmentParser.STREAM_TYPES = STREAM_TYPES = {
437 h264: 0x1b, 458 h264: 0x1b,
438 adts: 0x0f 459 adts: 0x0f,
460 metadata: 0x15
439 }; 461 };
440 462
441 })(window); 463 })(window);
......
...@@ -74,6 +74,44 @@ videojs.Hls.prototype.src = function(src) { ...@@ -74,6 +74,44 @@ videojs.Hls.prototype.src = function(src) {
74 this.segmentBuffer_ = []; 74 this.segmentBuffer_ = [];
75 this.segmentParser_ = new videojs.Hls.SegmentParser(); 75 this.segmentParser_ = new videojs.Hls.SegmentParser();
76 76
77 // if the stream contains ID3 metadata, expose that as a metadata
78 // text track
79 (function() {
80 var
81 metadataStream = tech.segmentParser_.metadataStream,
82 textTrack;
83
84 // only expose metadata tracks to video.js versions that support
85 // dynamic text tracks (4.12+)
86 if (!tech.player().addTextTrack) {
87 return;
88 }
89
90 metadataStream.on('data', function(metadata) {
91 var i, frame, time, hexDigit;
92
93 // create the metadata track if this is the first ID3 tag we've
94 // seen
95 if (!textTrack) {
96 textTrack = tech.player().addTextTrack('metadata', 'Timed Metadata');
97
98 // build the dispatch type from the stream descriptor
99 // https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track
100 textTrack.inBandMetadataTrackDispatchType = videojs.Hls.SegmentParser.STREAM_TYPES.metadata.toString(16).toUpperCase();
101 for (i = 0; i < metadataStream.descriptor.length; i++) {
102 hexDigit = ('00' + metadataStream.descriptor[i].toString(16).toUpperCase()).slice(-2);
103 textTrack.inBandMetadataTrackDispatchType += hexDigit;
104 }
105 }
106
107 for (i = 0; i < metadata.frames.length; i++) {
108 frame = metadata.frames[i];
109 time = metadata.pts / 1000;
110 textTrack.addCue(new window.VTTCue(time, time, frame.value || frame.url));
111 }
112 });
113 })();
114
77 // load the MediaSource into the player 115 // load the MediaSource into the player
78 this.mediaSource.addEventListener('sourceopen', videojs.bind(this, this.handleSourceOpen)); 116 this.mediaSource.addEventListener('sourceopen', videojs.bind(this, this.handleSourceOpen));
79 117
......
...@@ -80,14 +80,15 @@ module.exports = function(config) { ...@@ -80,14 +80,15 @@ module.exports = function(config) {
80 '../node_modules/pkcs7/dist/pkcs7.unpad.js', 80 '../node_modules/pkcs7/dist/pkcs7.unpad.js',
81 '../test/karma-qunit-shim.js', 81 '../test/karma-qunit-shim.js',
82 '../src/videojs-hls.js', 82 '../src/videojs-hls.js',
83 '../src/xhr.js', 83 '../src/stream.js',
84 '../src/flv-tag.js', 84 '../src/flv-tag.js',
85 '../src/exp-golomb.js', 85 '../src/exp-golomb.js',
86 '../src/h264-stream.js', 86 '../src/h264-stream.js',
87 '../src/aac-stream.js', 87 '../src/aac-stream.js',
88 '../src/metadata-stream.js',
88 '../src/segment-parser.js', 89 '../src/segment-parser.js',
89 '../src/stream.js',
90 '../src/m3u8/m3u8-parser.js', 90 '../src/m3u8/m3u8-parser.js',
91 '../src/xhr.js',
91 '../src/playlist-loader.js', 92 '../src/playlist-loader.js',
92 '../src/decrypter.js', 93 '../src/decrypter.js',
93 '../tmp/manifests.js', 94 '../tmp/manifests.js',
......
...@@ -44,14 +44,15 @@ module.exports = function(config) { ...@@ -44,14 +44,15 @@ module.exports = function(config) {
44 '../node_modules/pkcs7/dist/pkcs7.unpad.js', 44 '../node_modules/pkcs7/dist/pkcs7.unpad.js',
45 '../test/karma-qunit-shim.js', 45 '../test/karma-qunit-shim.js',
46 '../src/videojs-hls.js', 46 '../src/videojs-hls.js',
47 '../src/xhr.js', 47 '../src/stream.js',
48 '../src/flv-tag.js', 48 '../src/flv-tag.js',
49 '../src/exp-golomb.js', 49 '../src/exp-golomb.js',
50 '../src/h264-stream.js', 50 '../src/h264-stream.js',
51 '../src/aac-stream.js', 51 '../src/aac-stream.js',
52 '../src/metadata-stream.js',
52 '../src/segment-parser.js', 53 '../src/segment-parser.js',
53 '../src/stream.js',
54 '../src/m3u8/m3u8-parser.js', 54 '../src/m3u8/m3u8-parser.js',
55 '../src/xhr.js',
55 '../src/playlist-loader.js', 56 '../src/playlist-loader.js',
56 '../src/decrypter.js', 57 '../src/decrypter.js',
57 '../tmp/manifests.js', 58 '../tmp/manifests.js',
......
1 (function(window, videojs, undefined) {
2 'use strict';
3 /*
4 ======== A Handy Little QUnit Reference ========
5 http://api.qunitjs.com/
6
7 Test methods:
8 module(name, {[setup][ ,teardown]})
9 test(name, callback)
10 expect(numberOfAssertions)
11 stop(increment)
12 start(decrement)
13 Test assertions:
14 ok(value, [message])
15 equal(actual, expected, [message])
16 notEqual(actual, expected, [message])
17 deepEqual(actual, expected, [message])
18 notDeepEqual(actual, expected, [message])
19 strictEqual(actual, expected, [message])
20 notStrictEqual(actual, expected, [message])
21 throws(block, [expected], [message])
22 */
23
24 var metadataStream, stringToInts, stringToCString, id3Tag, id3Frame;
25
26 module('MetadataStream', {
27 setup: function() {
28 metadataStream = new videojs.Hls.MetadataStream();
29 }
30 });
31
32 test('can construct a MetadataStream', function() {
33 ok(metadataStream, 'does not return null');
34 });
35
36 stringToInts = function(string) {
37 var result = [], i;
38 for (i = 0; i < string.length; i++) {
39 result[i] = string.charCodeAt(i);
40 }
41 return result;
42 };
43
44 stringToCString = function(string) {
45 return stringToInts(string).concat([0x00]);
46 };
47
48 id3Tag = function() {
49 var
50 frames = Array.prototype.concat.apply([], Array.prototype.slice.call(arguments)),
51 result = stringToInts('ID3').concat([
52 0x03, 0x00, // version 3.0 of ID3v2 (aka ID3v.2.3.0)
53 0x40, // flags. include an extended header
54 0x00, 0x00, 0x00, 0x00, // size. set later
55
56 // extended header
57 0x00, 0x00, 0x00, 0x06, // extended header size. no CRC
58 0x00, 0x00, // extended flags
59 0x00, 0x00, 0x00, 0x02 // size of padding
60 ], frames),
61 size;
62
63 size = result.length - 10;
64 result[6] = (size >>> 24) & 0xff;
65 result[7] = (size >>> 16) & 0xff;
66 result[8] = (size >>> 8) & 0xff;
67 result[9] = (size) & 0xff;
68
69 return result;
70 };
71
72 id3Frame = function(type) {
73 var result = stringToInts(type).concat([
74 0x00, 0x00, 0x00, 0x00, // size
75 0xe0, 0x00 // flags. tag/file alter preservation, read-only
76 ]),
77 size = result.length - 10;
78
79 // append the fields of the ID3 frame
80 result = result.concat.apply(result, Array.prototype.slice.call(arguments, 1));
81
82 // set the size
83 size = result.length - 10;
84 result[4] = (size >>> 24);
85 result[5] = (size >>> 16) & 0xff;
86 result[6] = (size >>> 8) & 0xff;
87 result[7] = (size) & 0xff;
88
89 return result;
90 };
91
92 test('parses simple ID3 metadata out of PES packets', function() {
93 var
94 events = [],
95 wxxxPayload = [
96 0x00 // text encoding. ISO-8859-1
97 ].concat(stringToCString('ad tag URL'), // description
98 stringToInts('http://example.com/ad?v=1234&q=7')), // value
99 id3Bytes,
100 size;
101
102 metadataStream.on('data', function(event) {
103 events.push(event);
104 });
105
106 id3Bytes = new Uint8Array(stringToInts('ID3').concat([
107 0x03, 0x00, // version 3.0 of ID3v2 (aka ID3v.2.3.0)
108 0x40, // flags. include an extended header
109 0x00, 0x00, 0x00, 0x00, // size. set later
110
111 // extended header
112 0x00, 0x00, 0x00, 0x06, // extended header size. no CRC
113 0x00, 0x00, // extended flags
114 0x00, 0x00, 0x00, 0x02, // size of padding
115
116 // frame 0
117 // http://id3.org/id3v2.3.0#User_defined_text_information_frame
118 ], id3Frame('WXXX',
119 wxxxPayload), // value
120 // frame 1
121 // custom tag
122 id3Frame('XINF',
123 [
124 0x04, 0x03, 0x02, 0x01 // arbitrary data
125 ]), [
126 0x00, 0x00 // padding
127 ]));
128
129 // set header size field
130 size = id3Bytes.byteLength - 10;
131 id3Bytes[6] = (size >>> 21) & 0x7f;
132 id3Bytes[7] = (size >>> 14) & 0x7f;
133 id3Bytes[8] = (size >>> 7) & 0x7f;
134 id3Bytes[9] = (size) & 0x7f;
135
136 metadataStream.push({
137 trackId: 7,
138 pts: 1000,
139 dts: 1000,
140
141 // header
142 data: id3Bytes
143 });
144
145 equal(events.length, 1, 'parsed one tag');
146 equal(events[0].frames.length, 2, 'parsed two frames');
147 equal(events[0].frames[0].id, 'WXXX', 'parsed a WXXX frame');
148 deepEqual(new Uint8Array(events[0].frames[0].data),
149 new Uint8Array(wxxxPayload),
150 'attached the frame payload');
151 equal(events[0].frames[1].id, 'XINF', 'parsed a user-defined frame');
152 deepEqual(new Uint8Array(events[0].frames[1].data),
153 new Uint8Array([0x04, 0x03, 0x02, 0x01]),
154 'attached the frame payload');
155 equal(events[0].pts, 1000, 'did not modify the PTS');
156 equal(events[0].dts, 1000, 'did not modify the PTS');
157 });
158
159 test('skips non-ID3 metadata events', function() {
160 var events = [];
161 metadataStream.on('data', function(event) {
162 events.push(event);
163 });
164
165 metadataStream.push({
166 trackId: 7,
167 pts: 1000,
168 dts: 1000,
169
170 // header
171 data: new Uint8Array([0])
172 });
173
174 equal(events.length, 0, 'did not emit an event');
175 });
176
177 // missing cases:
178 // unsynchronization
179 // CRC
180 // no extended header
181 // compressed frames
182 // encrypted frames
183 // frame groups
184 // too large/small tag size values
185 // too large/small frame size values
186
187 test('translates PTS and DTS values based on the timestamp offset', function() {
188 var events = [];
189 metadataStream.on('data', function(event) {
190 events.push(event);
191 });
192
193 metadataStream.timestampOffset = 800;
194
195 metadataStream.push({
196 trackId: 7,
197 pts: 1000,
198 dts: 900,
199
200 // header
201 data: new Uint8Array(id3Tag(id3Frame('XFFF', [0]), [0x00, 0x00]))
202 });
203
204 equal(events.length, 1, 'emitted an event');
205 equal(events[0].pts, 200, 'translated pts');
206 equal(events[0].dts, 100, 'translated dts');
207 });
208
209 test('parses TXXX tags', function() {
210 var events = [];
211 metadataStream.on('data', function(event) {
212 events.push(event);
213 });
214
215 metadataStream.push({
216 trackId: 7,
217 pts: 1000,
218 dts: 900,
219
220 // header
221 data: new Uint8Array(id3Tag(id3Frame('TXXX',
222 0x03, // utf-8
223 stringToCString('get done'),
224 stringToInts('{ "key": "value" }')),
225 [0x00, 0x00]))
226 });
227
228 equal(events.length, 1, 'parsed one tag');
229 equal(events[0].frames.length, 1, 'parsed one frame');
230 equal(events[0].frames[0].description, 'get done', 'parsed the description');
231 equal(events[0].frames[0].value, '{ "key": "value" }', 'parsed the value');
232 });
233
234 test('parses WXXX tags', function() {
235 var events = [], url = 'http://example.com/path/file?abc=7&d=4#ty';
236 metadataStream.on('data', function(event) {
237 events.push(event);
238 });
239
240 metadataStream.push({
241 trackId: 7,
242 pts: 1000,
243 dts: 900,
244
245 // header
246 data: new Uint8Array(id3Tag(id3Frame('WXXX',
247 0x03, // utf-8
248 stringToCString(''),
249 stringToInts(url)),
250 [0x00, 0x00]))
251 });
252
253 equal(events.length, 1, 'parsed one tag');
254 equal(events[0].frames.length, 1, 'parsed one frame');
255 equal(events[0].frames[0].description, '', 'parsed the description');
256 equal(events[0].frames[0].url, url, 'parsed the value');
257 });
258
259 test('parses TXXX tags with characters that have a single-digit hexadecimal representation', function() {
260 var events = [], value = String.fromCharCode(7);
261 metadataStream.on('data', function(event) {
262 events.push(event);
263 });
264
265 metadataStream.push({
266 trackId: 7,
267 pts: 1000,
268 dts: 900,
269
270 // header
271 data: new Uint8Array(id3Tag(id3Frame('TXXX',
272 0x03, // utf-8
273 stringToCString(''),
274 stringToInts(value)),
275 [0x00, 0x00]))
276 });
277
278 equal(events[0].frames[0].value,
279 value,
280 'parsed the single-digit character');
281 });
282
283 // https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track
284 test('constructs the dispatch type', function() {
285 metadataStream = new videojs.Hls.MetadataStream({
286 descriptor: new Uint8Array([0x03, 0x02, 0x01, 0x00])
287 });
288
289 equal(metadataStream.dispatchType, '1503020100', 'built the dispatch type');
290 });
291
292 })(window, window.videojs);
...@@ -109,11 +109,18 @@ ...@@ -109,11 +109,18 @@
109 </div> 109 </div>
110 110
111 111
112 <script>
113 window.videojs = {
114 Hls: {}
115 };
116 </script>
112 <!-- transmuxing --> 117 <!-- transmuxing -->
118 <script src="../../src/stream.js"></script>
113 <script src="../../src/flv-tag.js"></script> 119 <script src="../../src/flv-tag.js"></script>
114 <script src="../../src/exp-golomb.js"></script> 120 <script src="../../src/exp-golomb.js"></script>
115 <script src="../../src/h264-stream.js"></script> 121 <script src="../../src/h264-stream.js"></script>
116 <script src="../../src/aac-stream.js"></script> 122 <script src="../../src/aac-stream.js"></script>
123 <script src="../../src/metadata-stream.js"></script>
117 <script src="../../src/segment-parser.js"></script> 124 <script src="../../src/segment-parser.js"></script>
118 <script src="../../node_modules/pkcs7/dist/pkcs7.unpad.js"></script> 125 <script src="../../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
119 <script src="../../src/decrypter.js"></script> 126 <script src="../../src/decrypter.js"></script>
......
...@@ -29,6 +29,11 @@ ...@@ -29,6 +29,11 @@
29 29
30 extend = window.videojs.util.mergeOptions, 30 extend = window.videojs.util.mergeOptions,
31 31
32 makePat,
33 makePsi,
34 makePmt,
35 makePacket,
36
32 testAudioTag, 37 testAudioTag,
33 testVideoTag, 38 testVideoTag,
34 testScriptTag, 39 testScriptTag,
...@@ -54,14 +59,15 @@ ...@@ -54,14 +59,15 @@
54 deepEqual(expectedHeader, header, 'the rest of the header is correct'); 59 deepEqual(expectedHeader, header, 'the rest of the header is correct');
55 }); 60 });
56 61
57 test('parses PMTs with program descriptors', function() { 62 // Create a PMT packet
58 var 63 // @return {Array} bytes
59 makePmt = function(options) { 64 makePmt = function(options) {
60 var 65 var
61 result = [], 66 result = [],
62 entryCount = 0, 67 entryCount = 0,
63 k, 68 k,
64 sectionLength; 69 sectionLength;
70
65 for (k in options.pids) { 71 for (k in options.pids) {
66 entryCount++; 72 entryCount++;
67 } 73 }
...@@ -108,11 +114,15 @@ ...@@ -108,11 +114,15 @@
108 // CRC_32 114 // CRC_32
109 result.push([0x00, 0x00, 0x00, 0x00]); // invalid CRC but we don't check it 115 result.push([0x00, 0x00, 0x00, 0x00]); // invalid CRC but we don't check it
110 return result; 116 return result;
111 }, 117 };
118
119 // Create a PAT packet
120 // @return {Array} bytes
112 makePat = function(options) { 121 makePat = function(options) {
113 var 122 var
114 result = [], 123 result = [],
115 k; 124 k;
125
116 // table_id 126 // table_id
117 result.push(0x00); 127 result.push(0x00);
118 // section_syntax_indicator '0' reserved section_length 128 // section_syntax_indicator '0' reserved section_length
...@@ -136,7 +146,10 @@ ...@@ -136,7 +146,10 @@
136 result.push(options.programs[k] & 0xff); 146 result.push(options.programs[k] & 0xff);
137 } 147 }
138 return result; 148 return result;
139 }, 149 };
150
151 // Create a PAT or PMT packet based on the specified options
152 // @return {Array} bytes
140 makePsi = function(options) { 153 makePsi = function(options) {
141 var result = []; 154 var result = [];
142 155
...@@ -148,7 +161,10 @@ ...@@ -148,7 +161,10 @@
148 return result.concat(makePat(options)); 161 return result.concat(makePat(options));
149 } 162 }
150 return result.concat(makePmt(options)); 163 return result.concat(makePmt(options));
151 }, 164 };
165
166 // Construct an M2TS packet
167 // @return {Array} bytes
152 makePacket = function(options) { 168 makePacket = function(options) {
153 var 169 var
154 result = [], 170 result = [],
...@@ -170,7 +186,10 @@ ...@@ -170,7 +186,10 @@
170 // ensure the resulting packet is the correct size 186 // ensure the resulting packet is the correct size
171 result.length = window.videojs.Hls.SegmentParser.MP2T_PACKET_LENGTH; 187 result.length = window.videojs.Hls.SegmentParser.MP2T_PACKET_LENGTH;
172 return result; 188 return result;
173 }, 189 };
190
191 test('parses PMTs with program descriptors', function() {
192 var
174 h264Type = window.videojs.Hls.SegmentParser.STREAM_TYPES.h264, 193 h264Type = window.videojs.Hls.SegmentParser.STREAM_TYPES.h264,
175 adtsType = window.videojs.Hls.SegmentParser.STREAM_TYPES.adts; 194 adtsType = window.videojs.Hls.SegmentParser.STREAM_TYPES.adts;
176 195
...@@ -191,6 +210,22 @@ ...@@ -191,6 +210,22 @@
191 strictEqual(parser.stream.programMapTable[adtsType], 0x03, 'audio is PID 3'); 210 strictEqual(parser.stream.programMapTable[adtsType], 0x03, 'audio is PID 3');
192 }); 211 });
193 212
213 test('recognizes metadata streams', function() {
214 parser.parseSegmentBinaryData(new Uint8Array(makePacket({
215 programs: {
216 0x01: [0x01]
217 }
218 }).concat(makePacket({
219 pid: 0x01,
220 pids: {
221 // Rec. ITU-T H.222.0 (06/2012), Table 2-34
222 0x02: 0x15 // Metadata carried in PES packets
223 }
224 }))));
225
226 equal(parser.stream.programMapTable[0x15], 0x02, 'metadata is PID 2');
227 });
228
194 test('parses the first bipbop segment', function() { 229 test('parses the first bipbop segment', function() {
195 parser.parseSegmentBinaryData(window.bcSegment); 230 parser.parseSegmentBinaryData(window.bcSegment);
196 231
......
...@@ -21,14 +21,15 @@ ...@@ -21,14 +21,15 @@
21 <!-- HLS plugin --> 21 <!-- HLS plugin -->
22 <script src="../src/videojs-hls.js"></script> 22 <script src="../src/videojs-hls.js"></script>
23 <script src="../src/xhr.js"></script> 23 <script src="../src/xhr.js"></script>
24 <script src="../src/stream.js"></script>
24 <script src="../src/flv-tag.js"></script> 25 <script src="../src/flv-tag.js"></script>
25 <script src="../src/exp-golomb.js"></script> 26 <script src="../src/exp-golomb.js"></script>
26 <script src="../src/h264-stream.js"></script> 27 <script src="../src/h264-stream.js"></script>
27 <script src="../src/aac-stream.js"></script> 28 <script src="../src/aac-stream.js"></script>
29 <script src="../src/metadata-stream.js"></script>
28 <script src="../src/segment-parser.js"></script> 30 <script src="../src/segment-parser.js"></script>
29 31
30 <!-- M3U8 --> 32 <!-- M3U8 -->
31 <script src="../src/stream.js"></script>
32 <script src="../src/m3u8/m3u8-parser.js"></script> 33 <script src="../src/m3u8/m3u8-parser.js"></script>
33 <script src="../src/playlist-loader.js"></script> 34 <script src="../src/playlist-loader.js"></script>
34 <script src="../node_modules/pkcs7/dist/pkcs7.unpad.js"></script> 35 <script src="../node_modules/pkcs7/dist/pkcs7.unpad.js"></script>
...@@ -55,6 +56,7 @@ ...@@ -55,6 +56,7 @@
55 <script src="h264-stream_test.js"></script> 56 <script src="h264-stream_test.js"></script>
56 <script src="exp-golomb_test.js"></script> 57 <script src="exp-golomb_test.js"></script>
57 <script src="flv-tag_test.js"></script> 58 <script src="flv-tag_test.js"></script>
59 <script src="metadata-stream_test.js"></script>
58 <script src="m3u8_test.js"></script> 60 <script src="m3u8_test.js"></script>
59 <script src="playlist-loader_test.js"></script> 61 <script src="playlist-loader_test.js"></script>
60 <script src="decrypter_test.js"></script> 62 <script src="decrypter_test.js"></script>
......
...@@ -110,6 +110,9 @@ var ...@@ -110,6 +110,9 @@ var
110 this.getNextTag = function() { 110 this.getNextTag = function() {
111 return tags.shift(); 111 return tags.shift();
112 }; 112 };
113 this.metadataStream = {
114 on: Function.prototype
115 };
113 }; 116 };
114 }; 117 };
115 118
...@@ -897,6 +900,9 @@ test('flushes the parser after each segment', function() { ...@@ -897,6 +900,9 @@ test('flushes the parser after each segment', function() {
897 flushes++; 900 flushes++;
898 }; 901 };
899 this.tagsAvailable = function() {}; 902 this.tagsAvailable = function() {};
903 this.metadataStream = {
904 on: Function.prototype
905 };
900 }; 906 };
901 907
902 player.src({ 908 player.src({
...@@ -910,6 +916,52 @@ test('flushes the parser after each segment', function() { ...@@ -910,6 +916,52 @@ test('flushes the parser after each segment', function() {
910 strictEqual(flushes, 1, 'tags are flushed at the end of a segment'); 916 strictEqual(flushes, 1, 'tags are flushed at the end of a segment');
911 }); 917 });
912 918
919 test('exposes in-band metadata events as cues', function() {
920 var track;
921 player.src({
922 src: 'manifest/media.m3u8',
923 type: 'application/vnd.apple.mpegurl'
924 });
925 openMediaSource(player);
926
927 player.hls.segmentParser_.parseSegmentBinaryData = function() {
928 // fake out a descriptor
929 player.hls.segmentParser_.metadataStream.descriptor = new Uint8Array([
930 1, 2, 3, 0xbb
931 ]);
932 // trigger a metadata event
933 player.hls.segmentParser_.metadataStream.trigger('data', {
934 pts: 2000,
935 data: new Uint8Array([]),
936 frames: [{
937 type: 'TXXX',
938 value: 'cue text'
939 }, {
940 type: 'WXXX',
941 url: 'http://example.com'
942 }]
943 });
944 };
945
946 standardXHRResponse(requests[0]);
947 standardXHRResponse(requests[1]);
948
949 equal(player.textTracks().length, 1, 'created a text track');
950 track = player.textTracks()[0];
951 equal(track.kind, 'metadata', 'kind is metadata');
952 equal(track.inBandMetadataTrackDispatchType, '15010203BB', 'set the dispatch type');
953 equal(track.cues.length, 2, 'created two cues');
954 equal(track.cues[0].startTime, 2, 'cue starts at 2 seconds');
955 equal(track.cues[0].endTime, 2, 'cue ends at 2 seconds');
956 equal(track.cues[0].pauseOnExit, false, 'cue does not pause on exit');
957 equal(track.cues[0].text, 'cue text', 'set cue text');
958
959 equal(track.cues[1].startTime, 2, 'cue starts at 2 seconds');
960 equal(track.cues[1].endTime, 2, 'cue ends at 2 seconds');
961 equal(track.cues[1].pauseOnExit, false, 'cue does not pause on exit');
962 equal(track.cues[1].text, 'http://example.com', 'set cue text');
963 });
964
913 test('drops tags before the target timestamp when seeking', function() { 965 test('drops tags before the target timestamp when seeking', function() {
914 var i = 10, 966 var i = 10,
915 tags = [], 967 tags = [],
......