Skip to content

Commit

Permalink
test: Finish TS StreamGenerator (#4739)
Browse files Browse the repository at this point in the history
This completes the TS StreamGenerator and updates test expectations to
match. With this, we get realistic simulated TS streams, which will be
important as a baseline for replacing mux.js with our own TS parser.

See also PR #4697
  • Loading branch information
joeyparrish committed Dec 8, 2022
1 parent 28c2769 commit 6df6e64
Show file tree
Hide file tree
Showing 3 changed files with 247 additions and 19 deletions.
41 changes: 32 additions & 9 deletions test/media/media_source_engine_integration.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,45 @@ describe('MediaSourceEngine', () => {
});

const tsCeaCue0 = jasmine.objectContaining({
startTime: Util.closeTo(2.167, 0.001),
endTime: Util.closeTo(6.372, 0.001),
startTime: Util.closeTo(0.767, 0.001),
endTime: Util.closeTo(4.972, 0.001),
textAlign: Cue.textAlign.CENTER,
payload: 'These are 608 captions\n(top left)',
});

const tsCeaCue1 = jasmine.objectContaining({
startTime: Util.closeTo(6.705, 0.001),
endTime: Util.closeTo(13.379, 0.001),
startTime: Util.closeTo(5.305, 0.001),
endTime: Util.closeTo(11.979, 0.001),
textAlign: Cue.textAlign.CENTER,
payload: 'These are 608 captions\n(middle)',
});

const tsCeaCue2 = jasmine.objectContaining({
startTime: Util.closeTo(13.712, 0.001),
endTime: Util.closeTo(20.719, 0.001),
startTime: Util.closeTo(12.312, 0.001),
endTime: Util.closeTo(19.319, 0.001),
textAlign: Cue.textAlign.CENTER,
payload: 'These are 608 captions\n(bottom left)',
});

// The same segments as above, but offset by 40 seconds (yes, 40), which is
// also 2 segments.
const tsCeaCue3 = jasmine.objectContaining({
startTime: Util.closeTo(40.767, 0.001),
endTime: Util.closeTo(44.972, 0.001),
textAlign: Cue.textAlign.CENTER,
payload: 'These are 608 captions\n(top left)',
});

const tsCeaCue4 = jasmine.objectContaining({
startTime: Util.closeTo(45.305, 0.001),
endTime: Util.closeTo(51.979, 0.001),
textAlign: Cue.textAlign.CENTER,
payload: 'These are 608 captions\n(middle)',
});

const tsCeaCue5 = jasmine.objectContaining({
startTime: Util.closeTo(52.312, 0.001),
endTime: Util.closeTo(59.319, 0.001),
textAlign: Cue.textAlign.CENTER,
payload: 'These are 608 captions\n(bottom left)',
});
Expand Down Expand Up @@ -450,9 +473,9 @@ describe('MediaSourceEngine', () => {
await append(ContentType.VIDEO, 2);

expect(textDisplayer.appendSpy).toHaveBeenCalledTimes(3);
expect(textDisplayer.appendSpy).toHaveBeenCalledWith([tsCeaCue0]);
expect(textDisplayer.appendSpy).toHaveBeenCalledWith([tsCeaCue1]);
expect(textDisplayer.appendSpy).toHaveBeenCalledWith([tsCeaCue2]);
expect(textDisplayer.appendSpy).toHaveBeenCalledWith([tsCeaCue3]);
expect(textDisplayer.appendSpy).toHaveBeenCalledWith([tsCeaCue4]);
expect(textDisplayer.appendSpy).toHaveBeenCalledWith([tsCeaCue5]);

textDisplayer.appendSpy.calls.reset();
await appendWithSeek(ContentType.VIDEO, 0);
Expand Down
220 changes: 212 additions & 8 deletions test/test/util/stream_generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,19 +51,36 @@ shaka.test.IStreamGenerator = class {
* @implements {shaka.test.IStreamGenerator}
*/
shaka.test.TSVodStreamGenerator = class {
/** @param {string} segmentUri The URI of the segment. */
constructor(segmentUri) {
/**
* @param {string} segmentUri The URI of the segment.
* @param {number} segmentDuration The duration of a single segment, in
* seconds.
*/
constructor(segmentUri, segmentDuration) {
/** @private {string} */
this.segmentUri_ = segmentUri;

/**
* Internally, everything is in timescale units.
* @private {number}
*/
this.segmentDuration_ = segmentDuration * 90000;

/** @private {!Array.<{offset: number, dts: ?number, pts: ?number}>} */
this.timestamps_ = [];

/** @private {number} */
this.timestampOffset_ = Infinity;

/** @private {ArrayBuffer} */
this.segment_ = null;
this.segmentTemplate_ = null;
}

/** @override */
async init() {
const segment = await shaka.test.Util.fetch(this.segmentUri_);
this.segment_ = segment;
this.segmentTemplate_ = segment;
this.parseSegment_();
}

/** @override */
Expand All @@ -75,11 +92,198 @@ shaka.test.TSVodStreamGenerator = class {
/** @override */
getSegment(position, wallClockTime) {
goog.asserts.assert(
this.segment_,
this.segmentTemplate_,
'init() must be called before getSegment().');
// TODO: complete implementation; this should change the timestamps based on
// the given wallClockTime, so as to simulate a long stream.
return this.segment_;

// This will create a copy of the given buffer.
const buffer = shaka.util.Uint8ArrayUtils.concat(this.segmentTemplate_);

for (const timestampMetadata of this.timestamps_) {
this.setTimestamp_(buffer, timestampMetadata, position);
}

return shaka.util.BufferUtils.toArrayBuffer(buffer);
}

/** @private */
parseSegment_() {
goog.asserts.assert(
this.segmentTemplate_,
'init() must be called before parseSegment_().');

// A TS segment can contain a timestamp in each 188-byte PES packet.
// Find all the timestamps and their offsets, and cache them.
const dataView = shaka.util.BufferUtils.toDataView(this.segmentTemplate_);
const reader = new shaka.util.DataViewReader(
dataView, shaka.util.DataViewReader.Endianness.BIG_ENDIAN);

// Read each TS packet (188 bytes).
for (let i = 0; i < reader.getLength(); i += 188) {
reader.seek(i);
const syncByte = reader.readUint8();
goog.asserts.assert(syncByte == 0x47, 'Sync byte not found!');

const flagsAndPacketId = reader.readUint16();
const packetId = flagsAndPacketId & 0x1fff;
if (packetId == 0x1fff) {
// A "null" TS packet. Skip it.
continue;
}

const hasPesPacket = flagsAndPacketId & 0x4000;
if (!hasPesPacket) {
// Not a PES packet. Skip it.
continue;
}

const flags = reader.readUint8();
const adaptationFieldControl = (flags & 0x30) >> 4;
if (adaptationFieldControl == 0 /* reserved */ ||
adaptationFieldControl == 2 /* adaptation field, no payload */) {
throw new Error(
`Unexpected adaptation field control: ${adaptationFieldControl}`);
}

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

// Now we come to the PES header (hopefully).
// Format reference: https://bit.ly/TsPES
const startCode = reader.readUint32();
const startCodePrefix = startCode >> 8;
if (startCodePrefix != 1) {
// Not a PES packet. Skip it.
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.
const ptsDtsIndicator = reader.readUint8() >> 6;
if (ptsDtsIndicator == 0 /* no timestamp */ ||
ptsDtsIndicator == 1 /* forbidden */) {
throw new Error(`Unexpected PTS/DTS flag: ${ptsDtsIndicator}`);
}

const pesHeaderLengthRemaining = reader.readUint8();
if (pesHeaderLengthRemaining == 0) {
throw new Error(`Malformed TS, no room for PTS/DTS!`);
}

const offset = reader.getPosition();
let pts = null;
let dts = null;

// Parse timestamps and keep track of the minimum timestamp seen, to use
// as a timestamp offset when we calculate new timestamps for a segment.
if (ptsDtsIndicator == 2 /* PTS only */) {
goog.asserts.assert(pesHeaderLengthRemaining == 5, 'Bad PES header?');
pts = this.parseTimestamp_(reader);
this.timestampOffset_ = Math.min(this.timestampOffset_, pts);
} else if (ptsDtsIndicator == 3 /* PTS and DTS */) {
goog.asserts.assert(pesHeaderLengthRemaining == 10, 'Bad PES header?');
pts = this.parseTimestamp_(reader);
dts = this.parseTimestamp_(reader);
this.timestampOffset_ = Math.min(this.timestampOffset_, pts, dts);
}

this.timestamps_.push({
offset,
pts,
dts,
});
}
}

/**
* @param {!shaka.util.DataViewReader} reader
* @return {number}
* @private
*/
parseTimestamp_(reader) {
const pts0 = reader.readUint8();
const pts1 = reader.readUint16();
const pts2 = reader.readUint16();
// Reconstruct 33-bit PTS from the 5-byte, padded structure.
const ptsHigh3 = (pts0 & 0x0e) >> 1;
const 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.
return ptsHigh3 * (1 << 30) + ptsLow30;
}

/**
* @param {!Uint8Array} buffer
* @param {{offset: number, pts: ?number, dts: ?number}} timestampMetadata
* @param {number} position
* @private
*/
setTimestamp_(buffer, timestampMetadata, position) {
// Wikipedia: "If only PTS is present, this is done by catenating 0010 ...
// If both PTS and DTS are present, first 4 bits are 0011 and first 4 bits
// for DTS are 0001."
const ptsHeader = timestampMetadata.dts == null ? 0b0010 : 0b0011;
const dtsHeader = 0b0001;

const segmentTime = this.segmentDuration_ * position;

if (timestampMetadata.pts != null) {
const pts = timestampMetadata.pts - this.timestampOffset_ + segmentTime;
this.writeTimestamp_(
buffer, timestampMetadata.offset, ptsHeader, this.overflow_(pts));
}
if (timestampMetadata.dts != null) {
const dts = timestampMetadata.dts - this.timestampOffset_ + segmentTime;
this.writeTimestamp_(
buffer, timestampMetadata.offset + 5, dtsHeader, this.overflow_(dts));
}
}

/**
* Write a timestamp (PTS or DTS) to a specific place in the buffer, with a
* specific header, in the PES timestamp layout.
*
* @param {!Uint8Array} buffer
* @param {number} offset Where to begin writing
* @param {number} header 4-bit header for the timestamp
* @param {number} timestamp the actual timestamp
* @private
*/
writeTimestamp_(buffer, offset, header, timestamp) {
// The 33 bit timestamp is split into parts, then packed into 40 bits
// (5 bytes). Wikipedia phrases the layout as: "0010b, most significant 3
// bits from PTS, 1, following next 15 bits, 1, rest 15 bits and 1."
// https://en.wikipedia.org/wiki/Packetized_elementary_stream
const top3Bits = timestamp >> 30;
const next15Bits = (timestamp >> 15) & 0x7fff;
const last15Bits = timestamp & 0x7fff;

buffer[offset + 0] = (header << 4) | (top3Bits << 1) | 1;
buffer[offset + 1] = next15Bits >> 7;
buffer[offset + 2] = ((next15Bits & 0x7f) << 1) | 1;
buffer[offset + 3] = last15Bits >> 7;
buffer[offset + 4] = ((last15Bits & 0x7f) << 1) | 1;
}

/**
* Handle PES timestamp overflow (33 bits).
*
* @param {number} timestamp
* @return {number} The same timestamp, with TS overflow applied.
* @private
*/
overflow_(timestamp) {
// NOTE: You can't get 2^33 with a bit-shift, because JavaScript will treat
// the number as a 32-bit int. Use Math.pow() instead. The result is
// still accurate as an integer.
const limit = Math.pow(2, 33);
while (timestamp >= limit) {
timestamp -= limit;
}
return timestamp;
}
};

Expand Down
5 changes: 3 additions & 2 deletions test/test/util/test_scheme.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ shaka.test.TestScheme = class {
*/
function createStreamGenerator(metadata) {
if (metadata.segmentUri.includes('.ts')) {
return new shaka.test.TSVodStreamGenerator(metadata.segmentUri);
return new shaka.test.TSVodStreamGenerator(
metadata.segmentUri, metadata.segmentDuration);
}
return new shaka.test.Mp4VodStreamGenerator(
metadata.initSegmentUri, metadata.mdhdOffset, metadata.segmentUri,
Expand Down Expand Up @@ -623,7 +624,7 @@ shaka.test.TestScheme.DATA = {
segmentUri: '/base/test/test/assets/captions-test.ts',
mimeType: 'video/mp2t',
codecs: 'avc1.64001e',
segmentDuration: 2,
segmentDuration: 20, // yes, this is accurate
},
text: {
mimeType: 'application/cea-608',
Expand Down

0 comments on commit 6df6e64

Please sign in to comment.