Buffer ID3 tags split across TS packets
If the entire ID3 frame isn't available in a single metadatastream push, store everything up and check with the combined data next time. Ignore tags that do not include at least the tag header in the first TS packet.
Showing
2 changed files
with
180 additions
and
51 deletions
... | @@ -6,23 +6,24 @@ | ... | @@ -6,23 +6,24 @@ |
6 | (function(window, videojs, undefined) { | 6 | (function(window, videojs, undefined) { |
7 | 'use strict'; | 7 | 'use strict'; |
8 | var | 8 | var |
9 | // return the string representation of the specified byte range, | 9 | // return a percent-encoded representation of the specified byte range |
10 | // interpreted as UTf-8. | 10 | // @see http://en.wikipedia.org/wiki/Percent-encoding |
11 | parseUtf8 = function(bytes, start, end) { | 11 | percentEncode = function(bytes, start, end) { |
12 | var i, result = ''; | 12 | var i, result = ''; |
13 | for (i = start; i < end; i++) { | 13 | for (i = start; i < end; i++) { |
14 | result += '%' + ('00' + bytes[i].toString(16)).slice(-2); | 14 | result += '%' + ('00' + bytes[i].toString(16)).slice(-2); |
15 | } | 15 | } |
16 | return window.decodeURIComponent(result); | 16 | return result; |
17 | }, | ||
18 | // return the string representation of the specified byte range, | ||
19 | // interpreted as UTf-8. | ||
20 | parseUtf8 = function(bytes, start, end) { | ||
21 | return window.decodeURIComponent(percentEncode(bytes, start, end)); | ||
17 | }, | 22 | }, |
18 | // return the string representation of the specified byte range, | 23 | // return the string representation of the specified byte range, |
19 | // interpreted as ISO-8859-1. | 24 | // interpreted as ISO-8859-1. |
20 | parseIso88591 = function(bytes, start, end) { | 25 | parseIso88591 = function(bytes, start, end) { |
21 | var i, result = ''; | 26 | return window.unescape(percentEncode(bytes, start, end)); |
22 | for (i = start; i < end; i++) { | ||
23 | result += '%' + ('00' + bytes[i].toString(16)).slice(-2); | ||
24 | } | ||
25 | return window.unescape(result); | ||
26 | }, | 27 | }, |
27 | tagParsers = { | 28 | tagParsers = { |
28 | 'TXXX': function(tag) { | 29 | 'TXXX': function(tag) { |
... | @@ -73,14 +74,23 @@ | ... | @@ -73,14 +74,23 @@ |
73 | MetadataStream; | 74 | MetadataStream; |
74 | 75 | ||
75 | MetadataStream = function(options) { | 76 | MetadataStream = function(options) { |
76 | var settings = { | 77 | var |
77 | debug: !!(options && options.debug), | 78 | settings = { |
78 | 79 | debug: !!(options && options.debug), | |
79 | // the bytes of the program-level descriptor field in MP2T | 80 | |
80 | // see ISO/IEC 13818-1:2013 (E), section 2.6 "Program and | 81 | // the bytes of the program-level descriptor field in MP2T |
81 | // program element descriptors" | 82 | // see ISO/IEC 13818-1:2013 (E), section 2.6 "Program and |
82 | descriptor: options && options.descriptor | 83 | // program element descriptors" |
83 | }, i; | 84 | descriptor: options && options.descriptor |
85 | }, | ||
86 | // the total size in bytes of the ID3 tag being parsed | ||
87 | tagSize = 0, | ||
88 | // tag data that is not complete enough to be parsed | ||
89 | buffer = [], | ||
90 | // the total number of bytes currently in the buffer | ||
91 | bufferSize = 0, | ||
92 | i; | ||
93 | |||
84 | MetadataStream.prototype.init.call(this); | 94 | MetadataStream.prototype.init.call(this); |
85 | 95 | ||
86 | // calculate the text track in-band metadata track dispatch type | 96 | // calculate the text track in-band metadata track dispatch type |
... | @@ -93,73 +103,110 @@ | ... | @@ -93,73 +103,110 @@ |
93 | } | 103 | } |
94 | 104 | ||
95 | this.push = function(chunk) { | 105 | this.push = function(chunk) { |
96 | var tagSize, frameStart, frameSize, frame; | 106 | var tag, frameStart, frameSize, frame, i; |
97 | 107 | ||
98 | // ignore events that don't look like ID3 data | 108 | // ignore events that don't look like ID3 data |
99 | if (chunk.data.length < 10 || | 109 | if (buffer.length === 0 && |
100 | chunk.data[0] !== 'I'.charCodeAt(0) || | 110 | (chunk.data.length < 10 || |
101 | chunk.data[1] !== 'D'.charCodeAt(0) || | 111 | chunk.data[0] !== 'I'.charCodeAt(0) || |
102 | chunk.data[2] !== '3'.charCodeAt(0)) { | 112 | chunk.data[1] !== 'D'.charCodeAt(0) || |
113 | chunk.data[2] !== '3'.charCodeAt(0))) { | ||
103 | if (settings.debug) { | 114 | if (settings.debug) { |
104 | videojs.log('Skipping unrecognized metadata stream'); | 115 | videojs.log('Skipping unrecognized metadata packet'); |
105 | } | 116 | } |
106 | return; | 117 | return; |
107 | } | 118 | } |
108 | 119 | ||
120 | // add this chunk to the data we've collected so far | ||
121 | buffer.push(chunk); | ||
122 | bufferSize += chunk.data.byteLength; | ||
123 | |||
124 | // grab the size of the entire frame from the ID3 header | ||
125 | if (buffer.length === 1) { | ||
126 | // the frame size is transmitted as a 28-bit integer in the | ||
127 | // last four bytes of the ID3 header. | ||
128 | // The most significant bit of each byte is dropped and the | ||
129 | // results concatenated to recover the actual value. | ||
130 | tagSize = (chunk.data[6] << 21) | | ||
131 | (chunk.data[7] << 14) | | ||
132 | (chunk.data[8] << 7) | | ||
133 | (chunk.data[9]); | ||
134 | |||
135 | // ID3 reports the tag size excluding the header but it's more | ||
136 | // convenient for our comparisons to include it | ||
137 | tagSize += 10; | ||
138 | } | ||
139 | |||
140 | // if the entire frame has not arrived, wait for more data | ||
141 | if (bufferSize < tagSize) { | ||
142 | return; | ||
143 | } | ||
144 | |||
145 | // collect the entire frame so it can be parsed | ||
146 | tag = { | ||
147 | data: new Uint8Array(tagSize), | ||
148 | frames: [], | ||
149 | pts: buffer[0].pts, | ||
150 | dts: buffer[0].dts | ||
151 | }; | ||
152 | for (i = 0; i < tagSize;) { | ||
153 | tag.data.set(buffer[0].data, i); | ||
154 | i += buffer[0].data.byteLength; | ||
155 | buffer.shift(); | ||
156 | } | ||
157 | |||
109 | // find the start of the first frame and the end of the tag | 158 | // find the start of the first frame and the end of the tag |
110 | tagSize = chunk.data.byteLength; | ||
111 | frameStart = 10; | 159 | frameStart = 10; |
112 | if (chunk.data[5] & 0x40) { | 160 | if (tag.data[5] & 0x40) { |
113 | // advance the frame start past the extended header | 161 | // advance the frame start past the extended header |
114 | frameStart += 4; // header size field | 162 | frameStart += 4; // header size field |
115 | frameStart += (chunk.data[10] << 24) | | 163 | frameStart += (tag.data[10] << 24) | |
116 | (chunk.data[11] << 16) | | 164 | (tag.data[11] << 16) | |
117 | (chunk.data[12] << 8) | | 165 | (tag.data[12] << 8) | |
118 | (chunk.data[13]); | 166 | (tag.data[13]); |
119 | 167 | ||
120 | // clip any padding off the end | 168 | // clip any padding off the end |
121 | tagSize -= (chunk.data[16] << 24) | | 169 | tagSize -= (tag.data[16] << 24) | |
122 | (chunk.data[17] << 16) | | 170 | (tag.data[17] << 16) | |
123 | (chunk.data[18] << 8) | | 171 | (tag.data[18] << 8) | |
124 | (chunk.data[19]); | 172 | (tag.data[19]); |
125 | } | 173 | } |
126 | 174 | ||
127 | // adjust the PTS values to align with the video and audio | 175 | // adjust the PTS values to align with the video and audio |
128 | // streams | 176 | // streams |
129 | if (this.timestampOffset) { | 177 | if (this.timestampOffset) { |
130 | chunk.pts -= this.timestampOffset; | 178 | tag.pts -= this.timestampOffset; |
131 | chunk.dts -= this.timestampOffset; | 179 | tag.dts -= this.timestampOffset; |
132 | } | 180 | } |
133 | 181 | ||
134 | // parse one or more ID3 frames | 182 | // parse one or more ID3 frames |
135 | // http://id3.org/id3v2.3.0#ID3v2_frame_overview | 183 | // http://id3.org/id3v2.3.0#ID3v2_frame_overview |
136 | chunk.frames = []; | ||
137 | do { | 184 | do { |
138 | // determine the number of bytes in this frame | 185 | // determine the number of bytes in this frame |
139 | frameSize = (chunk.data[frameStart + 4] << 24) | | 186 | frameSize = (tag.data[frameStart + 4] << 24) | |
140 | (chunk.data[frameStart + 5] << 16) | | 187 | (tag.data[frameStart + 5] << 16) | |
141 | (chunk.data[frameStart + 6] << 8) | | 188 | (tag.data[frameStart + 6] << 8) | |
142 | (chunk.data[frameStart + 7]); | 189 | (tag.data[frameStart + 7]); |
143 | if (frameSize < 1) { | 190 | if (frameSize < 1) { |
144 | return videojs.log('Malformed ID3 frame encountered. Skipping metadata parsing.'); | 191 | return videojs.log('Malformed ID3 frame encountered. Skipping metadata parsing.'); |
145 | } | 192 | } |
146 | 193 | ||
147 | frame = { | 194 | frame = { |
148 | id: String.fromCharCode(chunk.data[frameStart]) + | 195 | id: String.fromCharCode(tag.data[frameStart], |
149 | String.fromCharCode(chunk.data[frameStart + 1]) + | 196 | tag.data[frameStart + 1], |
150 | String.fromCharCode(chunk.data[frameStart + 2]) + | 197 | tag.data[frameStart + 2], |
151 | String.fromCharCode(chunk.data[frameStart + 3]), | 198 | tag.data[frameStart + 3]), |
152 | data: chunk.data.subarray(frameStart + 10, frameStart + frameSize + 10) | 199 | data: tag.data.subarray(frameStart + 10, frameStart + frameSize + 10) |
153 | }; | 200 | }; |
154 | if (tagParsers[frame.id]) { | 201 | if (tagParsers[frame.id]) { |
155 | tagParsers[frame.id](frame); | 202 | tagParsers[frame.id](frame); |
156 | } | 203 | } |
157 | chunk.frames.push(frame); | 204 | tag.frames.push(frame); |
158 | 205 | ||
159 | frameStart += 10; // advance past the frame header | 206 | frameStart += 10; // advance past the frame header |
160 | frameStart += frameSize; // advance past the frame body | 207 | frameStart += frameSize; // advance past the frame body |
161 | } while (frameStart < tagSize); | 208 | } while (frameStart < tagSize); |
162 | this.trigger('data', chunk); | 209 | this.trigger('data', tag); |
163 | }; | 210 | }; |
164 | }; | 211 | }; |
165 | MetadataStream.prototype = new videojs.Hls.Stream(); | 212 | MetadataStream.prototype = new videojs.Hls.Stream(); | ... | ... |
... | @@ -206,7 +206,7 @@ | ... | @@ -206,7 +206,7 @@ |
206 | equal(events[0].dts, 100, 'translated dts'); | 206 | equal(events[0].dts, 100, 'translated dts'); |
207 | }); | 207 | }); |
208 | 208 | ||
209 | test('parses TXXX tags', function() { | 209 | test('parses TXXX frames', function() { |
210 | var events = []; | 210 | var events = []; |
211 | metadataStream.on('data', function(event) { | 211 | metadataStream.on('data', function(event) { |
212 | events.push(event); | 212 | events.push(event); |
... | @@ -232,7 +232,7 @@ | ... | @@ -232,7 +232,7 @@ |
232 | equal(events[0].frames[0].value, '{ "key": "value" }', 'parsed the value'); | 232 | equal(events[0].frames[0].value, '{ "key": "value" }', 'parsed the value'); |
233 | }); | 233 | }); |
234 | 234 | ||
235 | test('parses WXXX tags', function() { | 235 | test('parses WXXX frames', function() { |
236 | var events = [], url = 'http://example.com/path/file?abc=7&d=4#ty'; | 236 | var events = [], url = 'http://example.com/path/file?abc=7&d=4#ty'; |
237 | metadataStream.on('data', function(event) { | 237 | metadataStream.on('data', function(event) { |
238 | events.push(event); | 238 | events.push(event); |
... | @@ -258,7 +258,7 @@ | ... | @@ -258,7 +258,7 @@ |
258 | equal(events[0].frames[0].url, url, 'parsed the value'); | 258 | equal(events[0].frames[0].url, url, 'parsed the value'); |
259 | }); | 259 | }); |
260 | 260 | ||
261 | test('parses TXXX tags with characters that have a single-digit hexadecimal representation', function() { | 261 | test('parses TXXX frames with characters that have a single-digit hexadecimal representation', function() { |
262 | var events = [], value = String.fromCharCode(7); | 262 | var events = [], value = String.fromCharCode(7); |
263 | metadataStream.on('data', function(event) { | 263 | metadataStream.on('data', function(event) { |
264 | events.push(event); | 264 | events.push(event); |
... | @@ -282,7 +282,7 @@ | ... | @@ -282,7 +282,7 @@ |
282 | 'parsed the single-digit character'); | 282 | 'parsed the single-digit character'); |
283 | }); | 283 | }); |
284 | 284 | ||
285 | test('parses PRIV tags', function() { | 285 | test('parses PRIV frames', function() { |
286 | var | 286 | var |
287 | events = [], | 287 | events = [], |
288 | payload = stringToInts('arbitrary data may be included in the payload ' + | 288 | payload = stringToInts('arbitrary data may be included in the payload ' + |
... | @@ -313,6 +313,88 @@ | ... | @@ -313,6 +313,88 @@ |
313 | 313 | ||
314 | }); | 314 | }); |
315 | 315 | ||
316 | test('parses tags split across pushes', function() { | ||
317 | var | ||
318 | events = [], | ||
319 | owner = stringToCString('owner@example.com'), | ||
320 | payload = stringToInts('A TS packet is 188 bytes in length so that it can' + | ||
321 | ' be easily transmitted over ATM networks, an ' + | ||
322 | 'important medium at one time. We want to be sure' + | ||
323 | ' that ID3 frames larger than a TS packet are ' + | ||
324 | 'properly re-assembled.'), | ||
325 | tag = new Uint8Array(id3Tag(id3Frame('PRIV', owner, payload))), | ||
326 | front = tag.subarray(0, 100), | ||
327 | back = tag.subarray(100); | ||
328 | |||
329 | metadataStream.on('data', function(event) { | ||
330 | events.push(event); | ||
331 | }); | ||
332 | |||
333 | metadataStream.push({ | ||
334 | trackId: 7, | ||
335 | pts: 1000, | ||
336 | dts: 900, | ||
337 | data: front | ||
338 | }); | ||
339 | |||
340 | equal(events.length, 0, 'parsed zero tags'); | ||
341 | |||
342 | metadataStream.push({ | ||
343 | trackId: 7, | ||
344 | pts: 1000, | ||
345 | dts: 900, | ||
346 | data: back | ||
347 | }); | ||
348 | |||
349 | equal(events.length, 1, 'parsed a tag'); | ||
350 | equal(events[0].frames.length, 1, 'parsed a frame'); | ||
351 | equal(events[0].frames[0].data.byteLength, | ||
352 | owner.length + payload.length, | ||
353 | 'collected data across pushes'); | ||
354 | }); | ||
355 | |||
356 | test('ignores tags when the header is fragmented', function() { | ||
357 | |||
358 | var | ||
359 | events = [], | ||
360 | tag = new Uint8Array(id3Tag(id3Frame('PRIV', | ||
361 | stringToCString('owner@example.com'), | ||
362 | stringToInts('payload')))), | ||
363 | // split the 10-byte ID3 tag header in half | ||
364 | front = tag.subarray(0, 5), | ||
365 | back = tag.subarray(5); | ||
366 | |||
367 | metadataStream.on('data', function(event) { | ||
368 | events.push(event); | ||
369 | }); | ||
370 | |||
371 | metadataStream.push({ | ||
372 | trackId: 7, | ||
373 | pts: 1000, | ||
374 | dts: 900, | ||
375 | data: front | ||
376 | }); | ||
377 | metadataStream.push({ | ||
378 | trackId: 7, | ||
379 | pts: 1000, | ||
380 | dts: 900, | ||
381 | data: back | ||
382 | }); | ||
383 | |||
384 | equal(events.length, 0, 'parsed zero tags'); | ||
385 | |||
386 | metadataStream.push({ | ||
387 | trackId: 7, | ||
388 | pts: 1500, | ||
389 | dts: 1500, | ||
390 | data: new Uint8Array(id3Tag(id3Frame('PRIV', | ||
391 | stringToCString('owner2'), | ||
392 | stringToInts('payload2')))) | ||
393 | }); | ||
394 | equal(events.length, 1, 'parsed one tag'); | ||
395 | equal(events[0].frames[0].owner, 'owner2', 'dropped the first tag'); | ||
396 | }); | ||
397 | |||
316 | // https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track | 398 | // https://html.spec.whatwg.org/multipage/embedded-content.html#steps-to-expose-a-media-resource-specific-text-track |
317 | test('constructs the dispatch type', function() { | 399 | test('constructs the dispatch type', function() { |
318 | metadataStream = new videojs.Hls.MetadataStream({ | 400 | metadataStream = new videojs.Hls.MetadataStream({ | ... | ... |
-
Please register or sign in to post a comment