Skip to content

Commit

Permalink
Get timestamps from HLS TS segments
Browse files Browse the repository at this point in the history
This adds a TS packet parser to find PTS values from the segments
themselves.

Issue #740

Change-Id: I267a9dbe5e4fc050ae63b5738c143a91cfa4f12b
  • Loading branch information
joeyparrish committed Nov 1, 2017
1 parent 5f81a46 commit a35c2d6
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 23 deletions.
113 changes: 113 additions & 0 deletions lib/hls/hls_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ goog.require('shaka.media.SegmentReference');
goog.require('shaka.net.DataUriPlugin');
goog.require('shaka.net.NetworkingEngine');
goog.require('shaka.text.TextEngine');
goog.require('shaka.util.DataViewReader');
goog.require('shaka.util.Error');
goog.require('shaka.util.Functional');
goog.require('shaka.util.ManifestParserUtils');
Expand Down Expand Up @@ -1244,6 +1245,8 @@ shaka.hls.HlsParser.prototype.getStartTime_ =
.then(function(response) {
if (mimeType == 'video/mp4' || mimeType == 'audio/mp4') {
return this.getStartTimeFromMp4Segment_(response.data);
} else if (mimeType == 'video/mp2t') {
return this.getStartTimeFromTsSegment_(response.data);
} else if (mimeType == 'application/mp4' ||
mimeType.indexOf('text/') == 0) {
return this.getStartTimeFromTextSegment_(
Expand Down Expand Up @@ -1295,6 +1298,116 @@ shaka.hls.HlsParser.prototype.getStartTimeFromMp4Segment_ = function(data) {
};


/**
* Parses a TS segment to get its start time.
*
* @param {!ArrayBuffer} data
* @return {number}
* @throws {shaka.util.Error}
* @private
*/
shaka.hls.HlsParser.prototype.getStartTimeFromTsSegment_ = function(data) {
var reader = new shaka.util.DataViewReader(
new DataView(data), shaka.util.DataViewReader.Endianness.BIG_ENDIAN);

var fail = function() {
throw new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_PARSE_SEGMENT_START_TIME);
};

var packetStart = 0;

var skipPacket = function() {
// 188-byte packets are standard, so assume that.
reader.seek(packetStart + 188);
syncByte = reader.readUint8();
if (syncByte != 0x47) {
// We haven't found the sync byte, so try it as a 192-byte packet.
reader.seek(packetStart + 192);
syncByte = reader.readUint8();
}
if (syncByte != 0x47) {
// We still haven't found the sync byte, so try as a 204-byte packet.
reader.seek(packetStart + 204);
syncByte = reader.readUint8();
}
if (syncByte != 0x47) {
fail();
}
// Put the sync byte back so we can read it in the next loop.
reader.rewind(1);
};

while (true) {
// Format reference: https://goo.gl/wk6wwu
packetStart = reader.getPosition();

var syncByte = reader.readUint8();
if (syncByte != 0x47) fail();

var flagsAndPacketId = reader.readUint16();
var hasPesPacket = flagsAndPacketId & 0x4000;
if (!hasPesPacket) fail();

var flags = reader.readUint8();
var adaptationFieldControl = (flags & 0x30) >> 4;
if (adaptationFieldControl == 0 /* reserved */ ||
adaptationFieldControl == 2 /* adaptation field, no payload */) {
fail();
}

if (adaptationFieldControl == 3) {
// Skip over adaptation field.
var length = reader.readUint8();
reader.skip(length);
}

// Now we come to the PES header (hopefully).
// Format reference: https://goo.gl/1166Mr
var startCode = reader.readUint32();
var startCodePrefix = startCode >> 8;
if (startCodePrefix != 1) {
// Not a PES packet yet. Skip this TS packet and try again.
skipPacket();
continue;
}

// Skip the 16-bit PES length and the first 8 bits of the optional header.
reader.skip(3);
// The next 8 bits contain flags about DTS & PTS.
var ptsDtsIndicator = reader.readUint8() >> 6;
if (ptsDtsIndicator == 0 /* no timestamp */ ||
ptsDtsIndicator == 1 /* forbidden */) {
fail();
}

var pesHeaderLengthRemaining = reader.readUint8();
if (pesHeaderLengthRemaining == 0) {
fail();
}

if (ptsDtsIndicator == 2 /* PTS only */) {
goog.asserts.assert(pesHeaderLengthRemaining == 5, 'Bad PES header?');
} else if (ptsDtsIndicator == 3 /* PTS and DTS */) {
goog.asserts.assert(pesHeaderLengthRemaining == 10, 'Bad PES header?');
}

var pts0 = reader.readUint8();
var pts1 = reader.readUint16();
var pts2 = reader.readUint16();
// Reconstruct 33-bit PTS from the 5-byte, padded structure.
var ptsHigh3 = (pts0 & 0x0e) >> 1;
var ptsLow30 = ((pts1 & 0xfffe) << 14) | ((pts2 & 0xfffe) >> 1);
// Reconstruct the PTS as a float. Avoid bitwise operations to combine
// because bitwise ops treat the values as 32-bit ints.
var pts = ptsHigh3 * (1 << 30) + ptsLow30;
return pts / shaka.hls.HlsParser.TS_TIMESCALE_;
}
};


/**
* Parses a text segment to get its start time.
*
Expand Down
31 changes: 31 additions & 0 deletions lib/util/data_view_reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,37 @@ shaka.util.DataViewReader.prototype.skip = function(bytes) {
};


/**
* Rewinds the specified number of bytes.
* @param {number} bytes The number of bytes to rewind.
* @throws {shaka.util.Error} when rewinding past the beginning of the data
* view.
* @export
*/
shaka.util.DataViewReader.prototype.rewind = function(bytes) {
goog.asserts.assert(bytes >= 0, 'Bad call to DataViewReader.rewind');
if (this.position_ < bytes) {
this.throwOutOfBounds_();
}
this.position_ -= bytes;
};


/**
* Seeks to a specified position.
* @param {number} position The desired byte position within the DataView.
* @throws {shaka.util.Error} when seeking outside the range of the data view.
* @export
*/
shaka.util.DataViewReader.prototype.seek = function(position) {
goog.asserts.assert(position >= 0, 'Bad call to DataViewReader.seek');
if (position < 0 || position > this.dataView_.byteLength) {
this.throwOutOfBounds_();
}
this.position_ = position;
};


/**
* Keeps reading until it reaches a byte that equals to zero. The text is
* assumed to be UTF-8.
Expand Down
39 changes: 38 additions & 1 deletion test/hls/hls_live_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ describe('HlsParser live', function() {
var toUTF8 = shaka.util.StringUtils.toUTF8;
/** @type {ArrayBuffer} */
var segmentData;
/** @type {ArrayBuffer} */
var tsSegmentData;
/** @const {number} */
var segmentDataStartTime;

Expand All @@ -57,6 +59,18 @@ describe('HlsParser live', function() {
0x00, 0x00, 0x00, 0x00, // baseMediaDecodeTime first 4 bytes
0x00, 0x02, 0xBF, 0x20 // baseMediaDecodeTime last 4 bytes (180000)
]).buffer;
tsSegmentData = new Uint8Array([
0x47, // TS sync byte (fixed value)
0x41, 0x01, // not corrupt, payload follows, packet ID 257
0x10, // not scrambled, no adaptation field, payload only, seq #0
0x00, 0x00, 0x01, // PES start code (fixed value)
0xe0, // stream ID (video stream 0)
0x00, 0x00, // PES packet length (doesn't matter)
0x80, // marker bits (fixed value), not scrambled, not priority
0x80, // PTS only, no DTS, other flags 0 (don't matter)
0x05, // remaining PES header length == 5 (one timestamp)
0x21, 0x00, 0x0b, 0x7e, 0x41 // PTS = 180000, encoded into 5 bytes
]).buffer;
// 180000 divided by TS timescale (90000) = segment starts at 2s.
segmentDataStartTime = 2;

Expand Down Expand Up @@ -390,7 +404,7 @@ describe('HlsParser live', function() {
mediaWithAdditionalSegment, [ref1, ref2]);
});

it('gets start time from mp4 segment', function(done) {
it('parses start time from mp4 segments', function(done) {
fakeNetEngine.setResponseMap({
'test://master': toUTF8(master),
'test://video': toUTF8(media),
Expand Down Expand Up @@ -453,6 +467,29 @@ describe('HlsParser live', function() {
shaka.polyfill.Promise.flush();
});

it('parses start time from ts segments', function(done) {
var tsMediaPlaylist = mediaWithRemovedSegment.replace(/\.mp4/g, '.ts');

fakeNetEngine.setResponseMap({
'test://master': toUTF8(master),
'test://video': toUTF8(tsMediaPlaylist),
'test://main2.ts': tsSegmentData
});

var ref = ManifestParser.makeReference(
'test://main2.ts', 1, segmentDataStartTime,
segmentDataStartTime + 2);

parser.start('test://master', playerInterface).then(function(manifest) {
var video = manifest.periods[0].variants[0].video;
ManifestParser.verifySegmentIndex(video, [ref]);
// In live content, we do not set presentationTimeOffset.
expect(video.presentationTimeOffset).toEqual(0);
}).catch(fail).then(done);

shaka.polyfill.Promise.flush();
});

it('gets start time of segments with byte range', function(done) {
fakeNetEngine.setResponseMap({
'test://master': toUTF8(master),
Expand Down
65 changes: 43 additions & 22 deletions test/hls/hls_parser_unit.js
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,8 @@ describe('HlsParser', function() {
describe('getStartTime_', function() {
/** @type {number} */
var segmentDataStartTime;
/** @type {ArrayBuffer} */
var tsSegmentData;

var master = [
'#EXTM3U\n',
Expand Down Expand Up @@ -1316,6 +1318,18 @@ describe('HlsParser', function() {
0x00, 0x00, 0x00, 0x00, // baseMediaDecodeTime first 4 bytes
0x00, 0x02, 0xBF, 0x20 // baseMediaDecodeTime last 4 bytes (180000)
]).buffer;
tsSegmentData = new Uint8Array([
0x47, // TS sync byte (fixed value)
0x41, 0x01, // not corrupt, payload follows, packet ID 257
0x10, // not scrambled, no adaptation field, payload only, seq #0
0x00, 0x00, 0x01, // PES start code (fixed value)
0xe0, // stream ID (video stream 0)
0x00, 0x00, // PES packet length (doesn't matter)
0x80, // marker bits (fixed value), not scrambled, not priority
0x80, // PTS only, no DTS, other flags 0 (don't matter)
0x05, // remaining PES header length == 5 (one timestamp)
0x21, 0x00, 0x0b, 0x7e, 0x41 // PTS = 180000, encoded into 5 bytes
]).buffer;
// 180000 divided by TS timescale (90000) = segment starts at 2s.
segmentDataStartTime = 2;
});
Expand Down Expand Up @@ -1353,6 +1367,35 @@ describe('HlsParser', function() {
}).catch(fail).then(done);
});

it('parses start time from ts segments', function(done) {
var tsMediaPlaylist = media.replace(/\.mp4/g, '.ts');

fakeNetEngine.setResponseMap({
'test://master': toUTF8(master),
'test://video': toUTF8(tsMediaPlaylist),
'test://main.ts': tsSegmentData
});

var ref = ManifestParser.makeReference(
'test://main.ts' /* uri */,
0 /* position */,
0 /* startTime */,
5 /* endTime */,
'' /* baseUri */,
expectedStartByte,
expectedEndByte);

parser.start('test://master', playerInterface)
.then(function(manifest) {
var video = manifest.periods[0].variants[0].video;
ManifestParser.verifySegmentIndex(video, [ref]);
// In VOD content, we set the presentationTimeOffset to align the
// content to presentation time 0.
expect(video.presentationTimeOffset)
.toEqual(segmentDataStartTime);
}).catch(fail).then(done);
});

it('sets duration with respect to presentation offset', function(done) {
fakeNetEngine.setResponseMap({
'test://master': toUTF8(master),
Expand All @@ -1373,27 +1416,5 @@ describe('HlsParser', function() {
expect(presentationTimeline.getDuration()).toEqual(5);
}).catch(fail).then(done);
});

it('cannot parse timestamps from non-mp4 content', function(done) {
var tsMediaPlaylist = media.replace(/\.mp4/g, '.ts');

fakeNetEngine.setResponseMap({
'test://master': toUTF8(master),
'test://video': toUTF8(tsMediaPlaylist),
'test://main.ts': new ArrayBuffer(10)
});

var error = new shaka.util.Error(
shaka.util.Error.Severity.CRITICAL,
shaka.util.Error.Category.MANIFEST,
shaka.util.Error.Code.HLS_COULD_NOT_PARSE_SEGMENT_START_TIME);

parser.start('test://master', playerInterface)
.then(fail)
.catch(function(e) {
shaka.test.Util.expectToEqualError(e, error);
})
.then(done);
});
});
});

0 comments on commit a35c2d6

Please sign in to comment.