diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c529babcec..8f71edf76a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,8 +4,11 @@ * Add `AudioListener` for listening to changes in audio configuration during playback ([#3994](https://github.com/google/ExoPlayer/issues/3994)). -* MPEG-TS: Support CEA-608/708 in H262 - ([#2565](https://github.com/google/ExoPlayer/issues/2565)). +* MPEG-TS: + * Support seeking for MPEG-TS Streams + ([#966](https://github.com/google/ExoPlayer/issues/966)). + * Support CEA-608/708 in H262 + ([#2565](https://github.com/google/ExoPlayer/issues/2565)). * MPEG-PS: Support reading duration and seeking for MPEG-PS Streams ([#4476](https://github.com/google/ExoPlayer/issues/4476)). * MediaSession extension: diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java new file mode 100644 index 0000000000..29aa0d55d2 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.BinarySearchSeeker; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.util.ParsableByteArray; +import com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; + +/** + * A seeker that supports seeking within TS stream using binary search. + * + *

This seeker uses the first and last PCR values within the stream, as well as the stream + * duration to interpolate the PCR value of the seeking position. Then it performs binary search + * within the stream to find a packets whose PCR value is within {@link #SEEK_TOLERANCE_US} from the + * target PCR. + */ +/* package */ final class TsBinarySearchSeeker extends BinarySearchSeeker { + + private static final long SEEK_TOLERANCE_US = 100_000; + private static final int MINIMUM_SEARCH_RANGE_BYTES = TsExtractor.TS_PACKET_SIZE * 5; + private static final int TIMESTAMP_SEARCH_PACKETS = 200; + private static final int TIMESTAMP_SEARCH_BYTES = + TsExtractor.TS_PACKET_SIZE * TIMESTAMP_SEARCH_PACKETS; + + public TsBinarySearchSeeker( + TimestampAdjuster pcrTimestampAdjuster, long streamDurationUs, long inputLength, int pcrPid) { + super( + new DefaultSeekTimestampConverter(), + new TsPcrSeeker(pcrPid, pcrTimestampAdjuster), + streamDurationUs, + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ streamDurationUs + 1, + /* floorBytePosition= */ 0, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ TsExtractor.TS_PACKET_SIZE, + MINIMUM_SEARCH_RANGE_BYTES); + } + + /** + * A {@link TimestampSeeker} implementation that looks for a given PCR timestamp at a given + * position in a TS stream. + * + *

Given a PCR timestamp, and a position within a TS stream, this seeker will try to read up to + * {@link #TIMESTAMP_SEARCH_PACKETS} TS packets from that stream position, look for all packet + * with PID equals to PCR_PID, and then compare the PCR timestamps (if available) of these packets + * vs the target timestamp. + */ + private static final class TsPcrSeeker implements TimestampSeeker { + + private final TimestampAdjuster pcrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + private final int pcrPid; + + public TsPcrSeeker(int pcrPid, TimestampAdjuster pcrTimestampAdjuster) { + this.pcrPid = pcrPid; + this.pcrTimestampAdjuster = pcrTimestampAdjuster; + packetBuffer = new ParsableByteArray(TIMESTAMP_SEARCH_BYTES); + } + + @Override + public TimestampSearchResult searchForTimestamp( + ExtractorInput input, long targetTimestamp, OutputFrameHolder outputFrameHolder) + throws IOException, InterruptedException { + long inputPosition = input.getPosition(); + int bytesToRead = + (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength() - input.getPosition()); + packetBuffer.reset(bytesToRead); + input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToRead); + + return searchForPcrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); + } + + private TimestampSearchResult searchForPcrValueInBuffer( + ParsableByteArray packetBuffer, long targetPcrTimeUs, long bufferStartOffset) { + int limit = packetBuffer.limit(); + + long startOfLastPacketPosition = C.POSITION_UNSET; + long endOfLastPacketPosition = C.POSITION_UNSET; + long lastPcrTimeUsInRange = C.TIME_UNSET; + + while (packetBuffer.bytesLeft() >= TsExtractor.TS_PACKET_SIZE) { + int startOfPacket = + TsUtil.findSyncBytePosition(packetBuffer.data, packetBuffer.getPosition(), limit); + int endOfPacket = startOfPacket + TsExtractor.TS_PACKET_SIZE; + if (endOfPacket > limit) { + break; + } + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, startOfPacket, pcrPid); + if (pcrValue != C.TIME_UNSET) { + long pcrTimeUs = pcrTimestampAdjuster.adjustTsTimestamp(pcrValue); + if (pcrTimeUs > targetPcrTimeUs) { + if (lastPcrTimeUsInRange == C.TIME_UNSET) { + // First PCR timestamp is already over target. + return TimestampSearchResult.overestimatedResult(pcrTimeUs, bufferStartOffset); + } else { + // Last PCR timestamp < target timestamp < this timestamp. + return TimestampSearchResult.targetFoundResult( + bufferStartOffset + startOfLastPacketPosition); + } + } else if (pcrTimeUs + SEEK_TOLERANCE_US > targetPcrTimeUs) { + long startOfPacketInStream = bufferStartOffset + startOfPacket; + return TimestampSearchResult.targetFoundResult(startOfPacketInStream); + } + + lastPcrTimeUsInRange = pcrTimeUs; + startOfLastPacketPosition = startOfPacket; + } + packetBuffer.setPosition(endOfPacket); + endOfLastPacketPosition = endOfPacket; + } + + if (lastPcrTimeUsInRange != C.TIME_UNSET) { + long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition; + return TimestampSearchResult.underestimatedResult( + lastPcrTimeUsInRange, endOfLastPacketPositionInStream); + } else { + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + } + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java index 450b3f5194..350337cc86 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java @@ -108,6 +108,14 @@ import java.io.IOException; return durationUs; } + /** + * Returns the {@link TimestampAdjuster} that this class uses to adjust timestamps read from the + * input TS stream. + */ + public TimestampAdjuster getPcrTimestampAdjuster() { + return pcrTimestampAdjuster; + } + private int finishReadDuration(ExtractorInput input) { isDurationRead = true; input.resetPeekPosition(); @@ -141,7 +149,7 @@ import java.io.IOException; if (packetBuffer.data[searchPosition] != TsExtractor.TS_SYNC_BYTE) { continue; } - long pcrValue = readPcrFromPacket(packetBuffer, searchPosition, pcrPid); + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); if (pcrValue != C.TIME_UNSET) { return pcrValue; } @@ -177,7 +185,7 @@ import java.io.IOException; if (packetBuffer.data[searchPosition] != TsExtractor.TS_SYNC_BYTE) { continue; } - long pcrValue = readPcrFromPacket(packetBuffer, searchPosition, pcrPid); + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); if (pcrValue != C.TIME_UNSET) { return pcrValue; } @@ -185,51 +193,4 @@ import java.io.IOException; return C.TIME_UNSET; } - private static long readPcrFromPacket( - ParsableByteArray packetBuffer, int startOfPacket, int pcrPid) { - packetBuffer.setPosition(startOfPacket); - if (packetBuffer.bytesLeft() < 5) { - // Header = 4 bytes, adaptationFieldLength = 1 byte. - return C.TIME_UNSET; - } - // Note: See ISO/IEC 13818-1, section 2.4.3.2 for details of the header format. - int tsPacketHeader = packetBuffer.readInt(); - if ((tsPacketHeader & 0x800000) != 0) { - // transport_error_indicator != 0 means there are uncorrectable errors in this packet. - return C.TIME_UNSET; - } - int pid = (tsPacketHeader & 0x1FFF00) >> 8; - if (pid != pcrPid) { - return C.TIME_UNSET; - } - boolean adaptationFieldExists = (tsPacketHeader & 0x20) != 0; - if (!adaptationFieldExists) { - return C.TIME_UNSET; - } - - int adaptationFieldLength = packetBuffer.readUnsignedByte(); - if (adaptationFieldLength >= 7 && packetBuffer.bytesLeft() >= 7) { - int flags = packetBuffer.readUnsignedByte(); - boolean pcrFlagSet = (flags & 0x10) == 0x10; - if (pcrFlagSet) { - byte[] pcrBytes = new byte[6]; - packetBuffer.readBytes(pcrBytes, /* offset= */ 0, pcrBytes.length); - return readPcrValueFromPcrBytes(pcrBytes); - } - } - return C.TIME_UNSET; - } - - /** - * Returns the value of PCR base - first 33 bits in big endian order from the PCR bytes. - * - *

We ignore PCR Ext, because it's too small to have any significance. - */ - private static long readPcrValueFromPcrBytes(byte[] pcrBytes) { - return (pcrBytes[0] & 0xFFL) << 25 - | (pcrBytes[1] & 0xFFL) << 17 - | (pcrBytes[2] & 0xFFL) << 9 - | (pcrBytes[3] & 0xFFL) << 1 - | (pcrBytes[4] & 0xFFL) >> 7; - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java index 47d2d7a296..f677dc008f 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -113,6 +113,7 @@ public final class TsExtractor implements Extractor { private final TsDurationReader durationReader; // Accessed only by the loading thread. + private TsBinarySearchSeeker tsBinarySearchSeeker; private ExtractorOutput output; private int remainingPmts; private boolean tracksEnded; @@ -208,7 +209,23 @@ public final class TsExtractor implements Extractor { Assertions.checkState(mode != MODE_HLS); int timestampAdjustersCount = timestampAdjusters.size(); for (int i = 0; i < timestampAdjustersCount; i++) { - timestampAdjusters.get(i).reset(); + TimestampAdjuster timestampAdjuster = timestampAdjusters.get(i); + boolean hasNotEncounteredFirstTimestamp = + timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET; + if (hasNotEncounteredFirstTimestamp + || (timestampAdjuster.getTimestampOffsetUs() != 0 + && timestampAdjuster.getFirstSampleTimestampUs() != timeUs)) { + // - If a track in the TS stream has not encountered any sample, it's going to treat the + // first sample encountered as timestamp 0, which is incorrect. So we have to set the first + // sample timestamp for that track manually. + // - If the timestamp adjuster has its timestamp set manually before, and now we seek to a + // different position, we need to set the first sample timestamp manually again. + timestampAdjuster.reset(); + timestampAdjuster.setFirstSampleTimestampUs(timeUs); + } + } + if (timeUs != 0 && tsBinarySearchSeeker != null) { + tsBinarySearchSeeker.setSeekTargetUs(timeUs); } tsPacketBuffer.reset(); continuityCounters.clear(); @@ -227,11 +244,12 @@ public final class TsExtractor implements Extractor { public @ReadResult int read(ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException { if (tracksEnded) { - boolean canReadDuration = input.getLength() != C.LENGTH_UNSET && mode != MODE_HLS; + long inputLength = input.getLength(); + boolean canReadDuration = inputLength != C.LENGTH_UNSET && mode != MODE_HLS; if (canReadDuration && !durationReader.isDurationReadFinished()) { return durationReader.readDuration(input, seekPosition, pcrPid); } - maybeOutputSeekMap(); + maybeOutputSeekMap(inputLength); if (pendingSeekToStart) { pendingSeekToStart = false; @@ -241,6 +259,11 @@ public final class TsExtractor implements Extractor { return RESULT_SEEK; } } + + if (tsBinarySearchSeeker != null && tsBinarySearchSeeker.isSeeking()) { + return tsBinarySearchSeeker.handlePendingSeek( + input, seekPosition, /* outputFrameHolder= */ null); + } } if (!fillBufferWithAtLeastOnePacket(input)) { @@ -314,10 +337,20 @@ public final class TsExtractor implements Extractor { // Internals. - private void maybeOutputSeekMap() { + private void maybeOutputSeekMap(long inputLength) { if (!hasOutputSeekMap) { hasOutputSeekMap = true; - output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); + if (durationReader.getDurationUs() != C.TIME_UNSET) { + tsBinarySearchSeeker = + new TsBinarySearchSeeker( + durationReader.getPcrTimestampAdjuster(), + durationReader.getDurationUs(), + inputLength, + pcrPid); + output.seekMap(tsBinarySearchSeeker.getSeekMap()); + } else { + output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); + } } } @@ -353,7 +386,7 @@ public final class TsExtractor implements Extractor { private int findEndOfFirstTsPacketInBuffer() throws ParserException { int searchStart = tsPacketBuffer.getPosition(); int limit = tsPacketBuffer.limit(); - int syncBytePosition = findSyncBytePosition(tsPacketBuffer.data, searchStart, limit); + int syncBytePosition = TsUtil.findSyncBytePosition(tsPacketBuffer.data, searchStart, limit); // Discard all bytes before the sync byte. // If sync byte is not found, this means discard the whole buffer. tsPacketBuffer.setPosition(syncBytePosition); @@ -370,18 +403,6 @@ public final class TsExtractor implements Extractor { return endOfPacket; } - /** - * Returns the position of the first TS_SYNC_BYTE within the range [startPosition, limitPosition) - * from the provided data array, or returns limitPosition if sync byte could not be found. - */ - private static int findSyncBytePosition(byte[] data, int startPosition, int limitPosition) { - int position = startPosition; - while (position < limitPosition && data[position] != TS_SYNC_BYTE) { - position++; - } - return position; - } - private boolean shouldConsumePacketPayload(int packetPid) { return mode == MODE_HLS || tracksEnded diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsUtil.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsUtil.java new file mode 100644 index 0000000000..2a7a0d25ab --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/TsUtil.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.exoplayer2.extractor.ts; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.util.ParsableByteArray; + +/** Utilities method for extracting MPEG-TS streams. */ +public final class TsUtil { + /** + * Returns the position of the first TS_SYNC_BYTE within the range [startPosition, limitPosition) + * from the provided data array, or returns limitPosition if sync byte could not be found. + */ + public static int findSyncBytePosition(byte[] data, int startPosition, int limitPosition) { + int position = startPosition; + while (position < limitPosition && data[position] != TsExtractor.TS_SYNC_BYTE) { + position++; + } + return position; + } + + /** + * Returns the PCR value read from a given TS packet. + * + * @param packetBuffer The buffer that holds the packet. + * @param startOfPacket The starting position of the packet in the buffer. + * @param pcrPid The PID for valid packets that contain PCR values. + * @return The PCR value read from the packet, if its PID is equal to {@code pcrPid} and it + * contains a valid PCR value. Returns {@link C#TIME_UNSET} otherwise. + */ + public static long readPcrFromPacket( + ParsableByteArray packetBuffer, int startOfPacket, int pcrPid) { + packetBuffer.setPosition(startOfPacket); + if (packetBuffer.bytesLeft() < 5) { + // Header = 4 bytes, adaptationFieldLength = 1 byte. + return C.TIME_UNSET; + } + // Note: See ISO/IEC 13818-1, section 2.4.3.2 for details of the header format. + int tsPacketHeader = packetBuffer.readInt(); + if ((tsPacketHeader & 0x800000) != 0) { + // transport_error_indicator != 0 means there are uncorrectable errors in this packet. + return C.TIME_UNSET; + } + int pid = (tsPacketHeader & 0x1FFF00) >> 8; + if (pid != pcrPid) { + return C.TIME_UNSET; + } + boolean adaptationFieldExists = (tsPacketHeader & 0x20) != 0; + if (!adaptationFieldExists) { + return C.TIME_UNSET; + } + + int adaptationFieldLength = packetBuffer.readUnsignedByte(); + if (adaptationFieldLength >= 7 && packetBuffer.bytesLeft() >= 7) { + int flags = packetBuffer.readUnsignedByte(); + boolean pcrFlagSet = (flags & 0x10) == 0x10; + if (pcrFlagSet) { + byte[] pcrBytes = new byte[6]; + packetBuffer.readBytes(pcrBytes, /* offset= */ 0, pcrBytes.length); + return readPcrValueFromPcrBytes(pcrBytes); + } + } + return C.TIME_UNSET; + } + + /** + * Returns the value of PCR base - first 33 bits in big endian order from the PCR bytes. + * + *

We ignore PCR Ext, because it's too small to have any significance. + */ + private static long readPcrValueFromPcrBytes(byte[] pcrBytes) { + return (pcrBytes[0] & 0xFFL) << 25 + | (pcrBytes[1] & 0xFFL) << 17 + | (pcrBytes[2] & 0xFFL) << 9 + | (pcrBytes[3] & 0xFFL) << 1 + | (pcrBytes[4] & 0xFFL) >> 7; + } + + private TsUtil() { + // Prevent instantiation. + } +} diff --git a/library/core/src/test/assets/ts/sample.ts.0.dump b/library/core/src/test/assets/ts/sample.ts.0.dump index d7b17eff6a..b45a32fd3a 100644 --- a/library/core/src/test/assets/ts/sample.ts.0.dump +++ b/library/core/src/test/assets/ts/sample.ts.0.dump @@ -1,5 +1,5 @@ seekMap: - isSeekable = false + isSeekable = true duration = 66733 getPosition(0) = [[timeUs=0, position=0]] numberOfTracks = 3 diff --git a/library/core/src/test/assets/ts/sample.ts.1.dump b/library/core/src/test/assets/ts/sample.ts.1.dump new file mode 100644 index 0000000000..7454a02141 --- /dev/null +++ b/library/core/src/test/assets/ts/sample.ts.1.dump @@ -0,0 +1,99 @@ +seekMap: + isSeekable = true + duration = 66733 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 3 +track 256: + format: + bitrate = -1 + id = 1/256 + containerMimeType = null + sampleMimeType = video/mpeg2 + maxInputSize = -1 + width = 640 + height = 426 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 22, hash CE183139 + total output bytes = 24315 + sample count = 1 + sample 0: + time = 55611 + flags = 0 + data = length 18112, hash EC44B35B +track 257: + format: + bitrate = -1 + id = 1/257 + containerMimeType = null + sampleMimeType = audio/mpeg-L2 + maxInputSize = 4096 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 1 + sampleRate = 44100 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = und + drmInitData = - + initializationData: + total output bytes = 5015 + sample count = 4 + sample 0: + time = 11333 + flags = 1 + data = length 1253, hash 727FD1C6 + sample 1: + time = 37455 + flags = 1 + data = length 1254, hash 73FB07B8 + sample 2: + time = 63578 + flags = 1 + data = length 1254, hash 73FB07B8 + sample 3: + time = 89700 + flags = 1 + data = length 1254, hash 73FB07B8 +track 8448: + format: + bitrate = -1 + id = 1/8448 + containerMimeType = null + sampleMimeType = application/cea-608 + maxInputSize = -1 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 0 + sample count = 0 +tracksEnded = true diff --git a/library/core/src/test/assets/ts/sample.ts.2.dump b/library/core/src/test/assets/ts/sample.ts.2.dump new file mode 100644 index 0000000000..c7cef05b93 --- /dev/null +++ b/library/core/src/test/assets/ts/sample.ts.2.dump @@ -0,0 +1,99 @@ +seekMap: + isSeekable = true + duration = 66733 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 3 +track 256: + format: + bitrate = -1 + id = 1/256 + containerMimeType = null + sampleMimeType = video/mpeg2 + maxInputSize = -1 + width = 640 + height = 426 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 22, hash CE183139 + total output bytes = 24315 + sample count = 1 + sample 0: + time = 77855 + flags = 0 + data = length 18112, hash EC44B35B +track 257: + format: + bitrate = -1 + id = 1/257 + containerMimeType = null + sampleMimeType = audio/mpeg-L2 + maxInputSize = 4096 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 1 + sampleRate = 44100 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = und + drmInitData = - + initializationData: + total output bytes = 5015 + sample count = 4 + sample 0: + time = 33577 + flags = 1 + data = length 1253, hash 727FD1C6 + sample 1: + time = 59699 + flags = 1 + data = length 1254, hash 73FB07B8 + sample 2: + time = 85822 + flags = 1 + data = length 1254, hash 73FB07B8 + sample 3: + time = 111944 + flags = 1 + data = length 1254, hash 73FB07B8 +track 8448: + format: + bitrate = -1 + id = 1/8448 + containerMimeType = null + sampleMimeType = application/cea-608 + maxInputSize = -1 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 0 + sample count = 0 +tracksEnded = true diff --git a/library/core/src/test/assets/ts/sample.ts.3.dump b/library/core/src/test/assets/ts/sample.ts.3.dump new file mode 100644 index 0000000000..d8238e1626 --- /dev/null +++ b/library/core/src/test/assets/ts/sample.ts.3.dump @@ -0,0 +1,87 @@ +seekMap: + isSeekable = true + duration = 66733 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 3 +track 256: + format: + bitrate = -1 + id = 1/256 + containerMimeType = null + sampleMimeType = video/mpeg2 + maxInputSize = -1 + width = 640 + height = 426 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + data = length 22, hash CE183139 + total output bytes = 0 + sample count = 0 +track 257: + format: + bitrate = -1 + id = 1/257 + containerMimeType = null + sampleMimeType = audio/mpeg-L2 + maxInputSize = 4096 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = 1 + sampleRate = 44100 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = und + drmInitData = - + initializationData: + total output bytes = 2508 + sample count = 2 + sample 0: + time = 66733 + flags = 1 + data = length 1254, hash 73FB07B8 + sample 1: + time = 92855 + flags = 1 + data = length 1254, hash 73FB07B8 +track 8448: + format: + bitrate = -1 + id = 1/8448 + containerMimeType = null + sampleMimeType = application/cea-608 + maxInputSize = -1 + width = -1 + height = -1 + frameRate = -1.0 + rotationDegrees = 0 + pixelWidthHeightRatio = 1.0 + channelCount = -1 + sampleRate = -1 + pcmEncoding = -1 + encoderDelay = 0 + encoderPadding = 0 + subsampleOffsetUs = 9223372036854775807 + selectionFlags = 0 + language = null + drmInitData = - + initializationData: + total output bytes = 0 + sample count = 0 +tracksEnded = true diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java index d5103aa682..c0a35427b0 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/AdtsExtractorSeekTest.java @@ -90,7 +90,7 @@ public final class AdtsExtractorSeekTest { SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(0); - long targetSeekTimeUs = 3330033; // 980_000; + long targetSeekTimeUs = 980_000; int extractedSampleIndex = TestUtil.seekToTimeUs( extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java new file mode 100644 index 0000000000..4d421b05a4 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorSeekTest.java @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.extractor.ts; + +import static com.google.common.truth.Truth.assertThat; + +import android.net.Uri; +import com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.ExtractorInput; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.extractor.SeekMap; +import com.google.android.exoplayer2.testutil.FakeExtractorOutput; +import com.google.android.exoplayer2.testutil.FakeTrackOutput; +import com.google.android.exoplayer2.testutil.TestUtil; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.Arrays; +import java.util.Random; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Seeking tests for {@link TsExtractor}. */ +@RunWith(RobolectricTestRunner.class) +public final class TsExtractorSeekTest { + + private static final String TEST_FILE = "ts/bbb_2500ms.ts"; + private static final int DURATION_US = 2_500_000; + private static final int AUDIO_TRACK_ID = 257; + private static final long MAXIMUM_TIMESTAMP_DELTA_US = 500_000L; + + private static final Random random = new Random(1234L); + + private FakeTrackOutput expectedTrackOutput; + private DefaultDataSource dataSource; + private PositionHolder positionHolder; + + @Before + public void setUp() throws IOException, InterruptedException { + positionHolder = new PositionHolder(); + expectedTrackOutput = + TestUtil.extractAllSamplesFromFile( + new TsExtractor(), RuntimeEnvironment.application, TEST_FILE) + .trackOutputs + .get(AUDIO_TRACK_ID); + + dataSource = + new DefaultDataSourceFactory(RuntimeEnvironment.application, "UserAgent") + .createDataSource(); + } + + @Test + public void testTsExtractorReads_nonSeekTableFile_returnSeekableSeekMap() + throws IOException, InterruptedException { + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE); + TsExtractor extractor = new TsExtractor(); + + SeekMap seekMap = + TestUtil.extractSeekMap(extractor, new FakeExtractorOutput(), dataSource, fileUri); + + assertThat(seekMap).isNotNull(); + assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US); + assertThat(seekMap.isSeekable()).isTrue(); + } + + @Test + public void testHandlePendingSeek_handlesSeekingToPositionInFile_extractsCorrectFrame() + throws IOException, InterruptedException { + TsExtractor extractor = new TsExtractor(); + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(AUDIO_TRACK_ID); + + long targetSeekTimeUs = 987_000; + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testHandlePendingSeek_handlesSeekToEoF_extractsLastFrame() + throws IOException, InterruptedException { + TsExtractor extractor = new TsExtractor(); + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(AUDIO_TRACK_ID); + + long targetSeekTimeUs = seekMap.getDurationUs(); + + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testHandlePendingSeek_handlesSeekingBackward_extractsCorrectFrame() + throws IOException, InterruptedException { + TsExtractor extractor = new TsExtractor(); + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(AUDIO_TRACK_ID); + + long firstSeekTimeUs = 987_000; + TestUtil.seekToTimeUs(extractor, seekMap, firstSeekTimeUs, dataSource, trackOutput, fileUri); + + long targetSeekTimeUs = 0; + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testHandlePendingSeek_handlesSeekingForward_extractsCorrectFrame() + throws IOException, InterruptedException { + TsExtractor extractor = new TsExtractor(); + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(AUDIO_TRACK_ID); + + long firstSeekTimeUs = 987_000; + TestUtil.seekToTimeUs(extractor, seekMap, firstSeekTimeUs, dataSource, trackOutput, fileUri); + + long targetSeekTimeUs = 1_234_000; + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testHandlePendingSeek_handlesRandomSeeks_extractsCorrectFrame() + throws IOException, InterruptedException { + TsExtractor extractor = new TsExtractor(); + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = TestUtil.extractSeekMap(extractor, extractorOutput, dataSource, fileUri); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(AUDIO_TRACK_ID); + + long numSeek = 100; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = random.nextInt(DURATION_US + 1); + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + } + + @Test + public void testHandlePendingSeek_handlesRandomSeeksAfterReadingFileOnce_extractsCorrectFrame() + throws IOException, InterruptedException { + TsExtractor extractor = new TsExtractor(); + Uri fileUri = TestUtil.buildAssetUri(TEST_FILE); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + readInputFileOnce(extractor, extractorOutput, fileUri); + SeekMap seekMap = extractorOutput.seekMap; + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(AUDIO_TRACK_ID); + + long numSeek = 100; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = random.nextInt(DURATION_US + 1); + int extractedFrameIndex = + TestUtil.seekToTimeUs( + extractor, seekMap, targetSeekTimeUs, dataSource, trackOutput, fileUri); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + } + + // Internal methods + + private void readInputFileOnce( + TsExtractor extractor, FakeExtractorOutput extractorOutput, Uri fileUri) + throws IOException, InterruptedException { + extractor.init(extractorOutput); + int readResult = Extractor.RESULT_CONTINUE; + ExtractorInput input = TestUtil.getExtractorInputFromPosition(dataSource, 0, fileUri); + while (readResult != Extractor.RESULT_END_OF_INPUT) { + try { + while (readResult == Extractor.RESULT_CONTINUE) { + readResult = extractor.read(input, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + if (readResult == Extractor.RESULT_SEEK) { + input = + TestUtil.getExtractorInputFromPosition(dataSource, positionHolder.position, fileUri); + readResult = Extractor.RESULT_CONTINUE; + } + } + } + + private void assertFirstFrameAfterSeekContainTargetSeekTime( + FakeTrackOutput trackOutput, long seekTimeUs, int firstFrameIndexAfterSeek) { + long outputSampleTimeUs = trackOutput.getSampleTimeUs(firstFrameIndexAfterSeek); + int expectedSampleIndex = + findOutputFrameInExpectedOutput(trackOutput.getSampleData(firstFrameIndexAfterSeek)); + // Assert that after seeking, the first sample frame written to output exists in the sample list + assertThat(expectedSampleIndex).isNotEqualTo(-1); + // Assert that the timestamp output for first sample after seek is near the seek point. + // For Ts seeking, unfortunately we can't guarantee exact frame seeking, since PID timestamp is + // not too reliable. + assertThat(Math.abs(outputSampleTimeUs - seekTimeUs)).isLessThan(MAXIMUM_TIMESTAMP_DELTA_US); + // Assert that the timestamp output for first sample after seek is near the actual sample + // at seek point. + // Note that the timestamp output for first sample after seek might *NOT* be equal to the + // timestamp of that same sample when reading from the beginning, because if first timestamp in + // the stream was not read before the seek, then the timestamp of the first sample after the + // seek is just approximated from the seek point. + assertThat( + Math.abs(outputSampleTimeUs - expectedTrackOutput.getSampleTimeUs(expectedSampleIndex))) + .isLessThan(MAXIMUM_TIMESTAMP_DELTA_US); + trackOutput.assertSample( + firstFrameIndexAfterSeek, + expectedTrackOutput.getSampleData(expectedSampleIndex), + outputSampleTimeUs, + expectedTrackOutput.getSampleFlags(expectedSampleIndex), + expectedTrackOutput.getSampleCryptoData(expectedSampleIndex)); + } + + private int findOutputFrameInExpectedOutput(byte[] sampleData) { + for (int i = 0; i < expectedTrackOutput.getSampleCount(); i++) { + byte[] currentSampleData = expectedTrackOutput.getSampleData(i); + if (Arrays.equals(currentSampleData, sampleData)) { + return i; + } + } + return -1; + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java index d984d4a104..2f3813e9e3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/TsExtractorTest.java @@ -61,15 +61,21 @@ public final class TsExtractorTest { } @Test - public void testIncompleteSample() throws Exception { + public void testStreamWithJunkData() throws Exception { Random random = new Random(0); byte[] fileData = TestUtil.getByteArray(RuntimeEnvironment.application, "ts/sample.ts"); ByteArrayOutputStream out = new ByteArrayOutputStream(fileData.length * 2); + int bytesLeft = fileData.length; + writeJunkData(out, random.nextInt(TS_PACKET_SIZE - 1) + 1); out.write(fileData, 0, TS_PACKET_SIZE * 5); - for (int i = TS_PACKET_SIZE * 5; i < fileData.length; i += TS_PACKET_SIZE) { + bytesLeft -= TS_PACKET_SIZE * 5; + + for (int i = TS_PACKET_SIZE * 5; i < fileData.length; i += 5 * TS_PACKET_SIZE) { writeJunkData(out, random.nextInt(TS_PACKET_SIZE)); - out.write(fileData, i, TS_PACKET_SIZE); + int length = Math.min(5 * TS_PACKET_SIZE, bytesLeft); + out.write(fileData, i, length); + bytesLeft -= length; } out.write(TS_SYNC_BYTE); writeJunkData(out, random.nextInt(TS_PACKET_SIZE - 1) + 1); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index 652183a91a..b732ae369c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -368,10 +368,13 @@ public class TestUtil { } /** Returns an {@link ExtractorInput} to read from the given input at given position. */ - private static ExtractorInput getExtractorInputFromPosition( + public static ExtractorInput getExtractorInputFromPosition( DataSource dataSource, long position, Uri uri) throws IOException { DataSpec dataSpec = new DataSpec(uri, position, C.LENGTH_UNSET, /* key= */ null); - long inputLength = dataSource.open(dataSpec); - return new DefaultExtractorInput(dataSource, position, inputLength); + long length = dataSource.open(dataSpec); + if (length != C.LENGTH_UNSET) { + length += position; + } + return new DefaultExtractorInput(dataSource, position, length); } }