diff --git a/RELEASENOTES.md b/RELEASENOTES.md index f5393b532b0..03a135d4d63 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -9,7 +9,7 @@ ([#2565](https://github.com/google/ExoPlayer/issues/2565)). * Fix bug preventing SCTE-35 cues from being output ([#4573](https://github.com/google/ExoPlayer/issues/4573)). -* MPEG-PS: Support reading duration from MPEG-PS Streams +* MPEG-PS: Support reading duration and seeking for MPEG-PS Streams ([#4476](https://github.com/google/ExoPlayer/issues/4476)). * MediaSession extension: * Allow apps to set custom errors. diff --git a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java index 269d50432b8..b9c6ea06dd4 100644 --- a/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java +++ b/extensions/flac/src/main/java/com/google/android/exoplayer2/ext/flac/FlacBinarySearchSeeker.java @@ -15,7 +15,6 @@ */ package com.google.android.exoplayer2.ext.flac; -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.extractor.SeekMap; @@ -75,7 +74,6 @@ public TimestampSearchResult searchForTimestamp( throws IOException, InterruptedException { ByteBuffer outputBuffer = outputFrameHolder.byteBuffer; long searchPosition = input.getPosition(); - int searchRangeBytes = getTimestampSearchBytesRange(); decoderJni.reset(searchPosition); try { decoderJni.decodeSampleWithBacktrackPosition( @@ -107,13 +105,6 @@ public TimestampSearchResult searchForTimestamp( return TimestampSearchResult.overestimatedResult(lastFrameSampleIndex, searchPosition); } } - - @Override - public int getTimestampSearchBytesRange() { - // We rely on decoderJni to search for timestamp (sample index) from a given stream point, so - // we don't restrict the range at all. - return C.LENGTH_UNSET; - } } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java index 8002c2871c4..435fb13648c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java @@ -72,14 +72,6 @@ protected interface TimestampSeeker { TimestampSearchResult searchForTimestamp( ExtractorInput input, long targetTimestamp, OutputFrameHolder outputFrameHolder) throws IOException, InterruptedException; - - /** - * The range of bytes from the current input position from which to search for the target - * timestamp. Uses {@link C#LENGTH_UNSET} to signal that there is no limit for the search range. - * - * @see #searchForTimestamp(ExtractorInput, long, OutputFrameHolder) - */ - int getTimestampSearchBytesRange(); } /** @@ -98,6 +90,18 @@ public OutputFrameHolder(ByteBuffer outputByteBuffer) { } } + /** + * A {@link SeekTimestampConverter} implementation that returns the seek time itself as the + * timestamp for a seek time position. + */ + public static final class DefaultSeekTimestampConverter implements SeekTimestampConverter { + + @Override + public long timeUsToTargetTime(long timeUs) { + return timeUs; + } + } + /** * A converter that converts seek time in stream time into target timestamp for the {@link * BinarySearchSeeker}. @@ -566,16 +570,4 @@ public long timeUsToTargetTime(long timeUs) { return seekTimestampConverter.timeUsToTargetTime(timeUs); } } - - /** - * A {@link SeekTimestampConverter} implementation that returns the seek time itself as the - * timestamp for a seek time position. - */ - private static final class DefaultSeekTimestampConverter implements SeekTimestampConverter { - - @Override - public long timeUsToTargetTime(long timeUs) { - return timeUs; - } - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java new file mode 100644 index 00000000000..e8c207f75d7 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java @@ -0,0 +1,205 @@ +/* + * 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 PS stream using binary search. + * + *
This seeker uses the first and last SCR values within the stream, as well as the stream + * duration to interpolate the SCR value of the seeking position. Then it performs binary search + * within the stream to find a packets whose SCR value is with in {@link #SEEK_TOLERANCE_US} from + * the target SCR. + */ +/* package */ final class PsBinarySearchSeeker extends BinarySearchSeeker { + + private static final long SEEK_TOLERANCE_US = 100_000; + private static final int MINIMUM_SEARCH_RANGE_BYTES = 1000; + private static final int TIMESTAMP_SEARCH_BYTES = 20000; + + public PsBinarySearchSeeker( + TimestampAdjuster scrTimestampAdjuster, long streamDurationUs, long inputLength) { + super( + new DefaultSeekTimestampConverter(), + new PsScrSeeker(scrTimestampAdjuster), + streamDurationUs, + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ streamDurationUs + 1, + /* floorBytePosition= */ 0, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ TsExtractor.TS_PACKET_SIZE, + MINIMUM_SEARCH_RANGE_BYTES); + } + + /** + * A seeker that looks for a given SCR timestamp at a given position in a PS stream. + * + *
Given a SCR timestamp, and a position within a PS stream, this seeker will try to read a + * range of up to {@link #TIMESTAMP_SEARCH_BYTES} bytes from that stream position, look for all + * packs in that range, and then compare the SCR timestamps (if available) of these packets vs the + * target timestamp. + */ + private static final class PsScrSeeker implements TimestampSeeker { + + private final TimestampAdjuster scrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + + private PsScrSeeker(TimestampAdjuster scrTimestampAdjuster) { + this.scrTimestampAdjuster = scrTimestampAdjuster; + 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 searchForScrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); + } + + private TimestampSearchResult searchForScrValueInBuffer( + ParsableByteArray packetBuffer, long targetScrTimeUs, long bufferStartOffset) { + int startOfLastPacketPosition = C.POSITION_UNSET; + int endOfLastPacketPosition = C.POSITION_UNSET; + long lastScrTimeUsInRange = C.TIME_UNSET; + + while (packetBuffer.bytesLeft() >= 4) { + int nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + if (nextStartCode != PsExtractor.PACK_START_CODE) { + packetBuffer.skipBytes(1); + continue; + } else { + packetBuffer.skipBytes(4); + } + + // We found a pack. + long scrValue = PsDurationReader.readScrValueFromPack(packetBuffer); + if (scrValue != C.TIME_UNSET) { + long scrTimeUs = scrTimestampAdjuster.adjustTsTimestamp(scrValue); + if (scrTimeUs > targetScrTimeUs) { + if (lastScrTimeUsInRange == C.TIME_UNSET) { + // First SCR timestamp is already over target. + return TimestampSearchResult.overestimatedResult(scrTimeUs, bufferStartOffset); + } else { + // Last SCR timestamp < target timestamp < this timestamp. + return TimestampSearchResult.targetFoundResult( + bufferStartOffset + startOfLastPacketPosition); + } + } else if (scrTimeUs + SEEK_TOLERANCE_US > targetScrTimeUs) { + long startOfPacketInStream = bufferStartOffset + packetBuffer.getPosition(); + return TimestampSearchResult.targetFoundResult(startOfPacketInStream); + } + + lastScrTimeUsInRange = scrTimeUs; + startOfLastPacketPosition = packetBuffer.getPosition(); + } + skipToEndOfCurrentPack(packetBuffer); + endOfLastPacketPosition = packetBuffer.getPosition(); + } + + if (lastScrTimeUsInRange != C.TIME_UNSET) { + long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition; + return TimestampSearchResult.underestimatedResult( + lastScrTimeUsInRange, endOfLastPacketPositionInStream); + } else { + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + } + + /** + * Skips the buffer position to the position after the end of the current PS pack in the buffer, + * given the byte position right after the {@link PsExtractor#PACK_START_CODE} of the pack in + * the buffer. If the pack ends after the end of the buffer, skips to the end of the buffer. + */ + private static void skipToEndOfCurrentPack(ParsableByteArray packetBuffer) { + int limit = packetBuffer.limit(); + + if (packetBuffer.bytesLeft() < 10) { + // We require at least 9 bytes for pack header to read SCR value + 1 byte for pack_stuffing + // length. + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(9); + + int packStuffingLength = packetBuffer.readUnsignedByte() & 0x07; + if (packetBuffer.bytesLeft() < packStuffingLength) { + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(packStuffingLength); + + if (packetBuffer.bytesLeft() < 4) { + packetBuffer.setPosition(limit); + return; + } + + int nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + if (nextStartCode == PsExtractor.SYSTEM_HEADER_START_CODE) { + packetBuffer.skipBytes(4); + int systemHeaderLength = packetBuffer.readUnsignedShort(); + if (packetBuffer.bytesLeft() < systemHeaderLength) { + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(systemHeaderLength); + } + + // Find the position of the next PACK_START_CODE or MPEG_PROGRAM_END_CODE, which is right + // after the end position of this pack. + // If we couldn't find these codes within the buffer, return the buffer limit, or return + // the first position which PES packets pattern does not match (some malformed packets). + while (packetBuffer.bytesLeft() >= 4) { + nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + if (nextStartCode == PsExtractor.PACK_START_CODE + || nextStartCode == PsExtractor.MPEG_PROGRAM_END_CODE) { + break; + } + if (nextStartCode >>> 8 != PsExtractor.PACKET_START_CODE_PREFIX) { + break; + } + packetBuffer.skipBytes(4); + + if (packetBuffer.bytesLeft() < 2) { + // 2 bytes for PES_packet length. + packetBuffer.setPosition(limit); + return; + } + int pesPacketLength = packetBuffer.readUnsignedShort(); + packetBuffer.setPosition( + Math.min(packetBuffer.limit(), packetBuffer.getPosition() + pesPacketLength)); + } + } + } + + private static int peekIntAtPosition(byte[] data, int position) { + return (data[position] & 0xFF) << 24 + | (data[position + 1] & 0xFF) << 16 + | (data[position + 2] & 0xFF) << 8 + | (data[position + 3] & 0xFF); + } +} diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java index fa4be4b2871..3b522062358 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java @@ -64,6 +64,10 @@ public boolean isDurationReadFinished() { return isDurationRead; } + public TimestampAdjuster getScrTimestampAdjuster() { + return scrTimestampAdjuster; + } + /** * Reads a PS duration from the input. * @@ -105,6 +109,25 @@ public long getDurationUs() { return durationUs; } + /** + * Returns the SCR value read from the next pack in the stream, given the buffer at the pack + * header start position (just behind the pack start code). + */ + public static long readScrValueFromPack(ParsableByteArray packetBuffer) { + int originalPosition = packetBuffer.getPosition(); + if (packetBuffer.bytesLeft() < 9) { + // We require at 9 bytes for pack header to read scr value + return C.TIME_UNSET; + } + byte[] scrBytes = new byte[9]; + packetBuffer.readBytes(scrBytes, /* offset= */ 0, scrBytes.length); + packetBuffer.setPosition(originalPosition); + if (!checkMarkerBits(scrBytes)) { + return C.TIME_UNSET; + } + return readScrValueFromPackHeader(scrBytes); + } + private int finishReadDuration(ExtractorInput input) { isDurationRead = true; input.resetPeekPosition(); @@ -135,9 +158,10 @@ private long readFirstScrValueFromBuffer(ParsableByteArray packetBuffer) { for (int searchPosition = searchStartPosition; searchPosition < searchEndPosition - 3; searchPosition++) { - int nextStartCode = peakIntAtPosition(packetBuffer.data, searchPosition); + int nextStartCode = peekIntAtPosition(packetBuffer.data, searchPosition); if (nextStartCode == PsExtractor.PACK_START_CODE) { - long scrValue = readScrValueFromPack(packetBuffer, searchPosition + 4); + packetBuffer.setPosition(searchPosition + 4); + long scrValue = readScrValueFromPack(packetBuffer); if (scrValue != C.TIME_UNSET) { return scrValue; } @@ -171,9 +195,10 @@ private long readLastScrValueFromBuffer(ParsableByteArray packetBuffer) { for (int searchPosition = searchEndPosition - 4; searchPosition >= searchStartPosition; searchPosition--) { - int nextStartCode = peakIntAtPosition(packetBuffer.data, searchPosition); + int nextStartCode = peekIntAtPosition(packetBuffer.data, searchPosition); if (nextStartCode == PsExtractor.PACK_START_CODE) { - long scrValue = readScrValueFromPack(packetBuffer, searchPosition + 4); + packetBuffer.setPosition(searchPosition + 4); + long scrValue = readScrValueFromPack(packetBuffer); if (scrValue != C.TIME_UNSET) { return scrValue; } @@ -182,28 +207,14 @@ private long readLastScrValueFromBuffer(ParsableByteArray packetBuffer) { return C.TIME_UNSET; } - private int peakIntAtPosition(byte[] data, int position) { + private int peekIntAtPosition(byte[] data, int position) { return (data[position] & 0xFF) << 24 | (data[position + 1] & 0xFF) << 16 | (data[position + 2] & 0xFF) << 8 | (data[position + 3] & 0xFF); } - private long readScrValueFromPack(ParsableByteArray packetBuffer, int packHeaderStartPosition) { - packetBuffer.setPosition(packHeaderStartPosition); - if (packetBuffer.bytesLeft() < 9) { - // We require at 9 bytes for pack header to read scr value - return C.TIME_UNSET; - } - byte[] scrBytes = new byte[9]; - packetBuffer.readBytes(scrBytes, /* offset= */ 0, scrBytes.length); - if (!checkMarkerBits(scrBytes)) { - return C.TIME_UNSET; - } - return readScrValueFromPackHeader(scrBytes); - } - - private boolean checkMarkerBits(byte[] scrBytes) { + private static boolean checkMarkerBits(byte[] scrBytes) { // Verify the 01xxx1xx marker on the 0th byte if ((scrBytes[0] & 0xC4) != 0x44) { return false; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java index 49ab8de8879..c7a082aeac5 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/extractor/ts/PsExtractor.java @@ -39,9 +39,9 @@ public final class PsExtractor implements Extractor { public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new PsExtractor()}; /* package */ static final int PACK_START_CODE = 0x000001BA; - private static final int SYSTEM_HEADER_START_CODE = 0x000001BB; - private static final int PACKET_START_CODE_PREFIX = 0x000001; - private static final int MPEG_PROGRAM_END_CODE = 0x000001B9; + /* package */ static final int SYSTEM_HEADER_START_CODE = 0x000001BB; + /* package */ static final int PACKET_START_CODE_PREFIX = 0x000001; + /* package */ static final int MPEG_PROGRAM_END_CODE = 0x000001B9; private static final int MAX_STREAM_ID_PLUS_ONE = 0x100; // Max search length for first audio and video track in input data. @@ -67,6 +67,7 @@ public final class PsExtractor implements Extractor { private long lastTrackPosition; // Accessed only by the loading thread. + private PsBinarySearchSeeker psBinarySearchSeeker; private ExtractorOutput output; private boolean hasOutputSeekMap; @@ -129,7 +130,23 @@ public void init(ExtractorOutput output) { @Override public void seek(long position, long timeUs) { - timestampAdjuster.reset(); + boolean hasNotEncounteredFirstTimestamp = + timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET; + if (hasNotEncounteredFirstTimestamp + || (timestampAdjuster.getFirstSampleTimestampUs() != 0 + && timestampAdjuster.getFirstSampleTimestampUs() != timeUs)) { + // - If the timestamp adjuster in the PS stream has not encountered any sample, it's going to + // treat the first timestamp encountered as sample time 0, which is incorrect. In this case, + // we have to set the first sample timestamp 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 (psBinarySearchSeeker != null) { + psBinarySearchSeeker.setSeekTargetUs(timeUs); + } for (int i = 0; i < psPayloadReaders.size(); i++) { psPayloadReaders.valueAt(i).seek(); } @@ -144,12 +161,23 @@ public void release() { public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException, InterruptedException { - boolean canReadDuration = input.getLength() != C.LENGTH_UNSET; + long inputLength = input.getLength(); + boolean canReadDuration = inputLength != C.LENGTH_UNSET; if (canReadDuration && !durationReader.isDurationReadFinished()) { return durationReader.readDuration(input, seekPosition); } - maybeOutputSeekMap(); + maybeOutputSeekMap(inputLength); + if (psBinarySearchSeeker != null && psBinarySearchSeeker.isSeeking()) { + return psBinarySearchSeeker.handlePendingSeek( + input, seekPosition, /* outputFrameHolder= */ null); + } + input.resetPeekPosition(); + long peekBytesLeft = + inputLength != C.LENGTH_UNSET ? inputLength - input.getPeekPosition() : C.LENGTH_UNSET; + if (peekBytesLeft != C.LENGTH_UNSET && peekBytesLeft < 4) { + return RESULT_END_OF_INPUT; + } // First peek and check what type of start code is next. if (!input.peekFully(psPacketBuffer.data, 0, 4, true)) { return RESULT_END_OF_INPUT; @@ -251,10 +279,19 @@ public int read(ExtractorInput input, PositionHolder seekPosition) // 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) { + psBinarySearchSeeker = + new PsBinarySearchSeeker( + durationReader.getScrTimestampAdjuster(), + durationReader.getDurationUs(), + inputLength); + output.seekMap(psBinarySearchSeeker.getSeekMap()); + } else { + output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); + } } } diff --git a/library/core/src/test/assets/ts/elephants_dream.mpg b/library/core/src/test/assets/ts/elephants_dream.mpg new file mode 100644 index 00000000000..05a1d17f4b7 Binary files /dev/null and b/library/core/src/test/assets/ts/elephants_dream.mpg differ diff --git a/library/core/src/test/assets/ts/sample.ps.0.dump b/library/core/src/test/assets/ts/sample.ps.0.dump index bfb1fcf5eed..06ef48de7a8 100644 --- a/library/core/src/test/assets/ts/sample.ps.0.dump +++ b/library/core/src/test/assets/ts/sample.ps.0.dump @@ -1,5 +1,5 @@ seekMap: - isSeekable = false + isSeekable = true duration = 766 getPosition(0) = [[timeUs=0, position=0]] numberOfTracks = 2 diff --git a/library/core/src/test/assets/ts/sample.ps.1.dump b/library/core/src/test/assets/ts/sample.ps.1.dump new file mode 100644 index 00000000000..ce0f223bd44 --- /dev/null +++ b/library/core/src/test/assets/ts/sample.ps.1.dump @@ -0,0 +1,59 @@ +seekMap: + isSeekable = true + duration = 766 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 2 +track 192: + format: + bitrate = -1 + id = 192 + 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 = null + drmInitData = - + initializationData: + total output bytes = 0 + sample count = 0 +track 224: + format: + bitrate = -1 + id = 224 + 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 743CC6F8 + total output bytes = 33949 + sample count = 1 + sample 0: + time = 80000 + flags = 0 + data = length 17831, hash 5C5A57F5 +tracksEnded = true diff --git a/library/core/src/test/assets/ts/sample.ps.2.dump b/library/core/src/test/assets/ts/sample.ps.2.dump new file mode 100644 index 00000000000..7d0a77037db --- /dev/null +++ b/library/core/src/test/assets/ts/sample.ps.2.dump @@ -0,0 +1,55 @@ +seekMap: + isSeekable = true + duration = 766 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 2 +track 192: + format: + bitrate = -1 + id = 192 + 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 = null + drmInitData = - + initializationData: + total output bytes = 0 + sample count = 0 +track 224: + format: + bitrate = -1 + id = 224 + 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 743CC6F8 + total output bytes = 19791 + sample count = 0 +tracksEnded = true diff --git a/library/core/src/test/assets/ts/sample.ps.3.dump b/library/core/src/test/assets/ts/sample.ps.3.dump new file mode 100644 index 00000000000..a7258cd7efb --- /dev/null +++ b/library/core/src/test/assets/ts/sample.ps.3.dump @@ -0,0 +1,55 @@ +seekMap: + isSeekable = true + duration = 766 + getPosition(0) = [[timeUs=0, position=0]] +numberOfTracks = 2 +track 192: + format: + bitrate = -1 + id = 192 + 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 = null + drmInitData = - + initializationData: + total output bytes = 0 + sample count = 0 +track 224: + format: + bitrate = -1 + id = 224 + 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 743CC6F8 + total output bytes = 1585 + sample count = 0 +tracksEnded = true diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/PsDurationReaderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/PsDurationReaderTest.java new file mode 100644 index 00000000000..418b2726bfd --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/PsDurationReaderTest.java @@ -0,0 +1,86 @@ +/* + * 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 com.google.android.exoplayer2.extractor.Extractor; +import com.google.android.exoplayer2.extractor.PositionHolder; +import com.google.android.exoplayer2.testutil.FakeExtractorInput; +import com.google.android.exoplayer2.testutil.TestUtil; +import java.io.IOException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +/** Unit test for {@link PsDurationReader}. */ +@RunWith(RobolectricTestRunner.class) +public final class PsDurationReaderTest { + + private PsDurationReader tsDurationReader; + private PositionHolder seekPositionHolder; + + @Before + public void setUp() { + tsDurationReader = new PsDurationReader(); + seekPositionHolder = new PositionHolder(); + } + + @Test + public void testIsDurationReadPending_returnFalseByDefault() { + assertThat(tsDurationReader.isDurationReadFinished()).isFalse(); + } + + @Test + public void testReadDuration_returnsCorrectDuration() throws IOException, InterruptedException { + FakeExtractorInput input = + new FakeExtractorInput.Builder() + .setData(TestUtil.getByteArray(RuntimeEnvironment.application, "ts/sample.ps")) + .build(); + + int result = Extractor.RESULT_CONTINUE; + while (!tsDurationReader.isDurationReadFinished()) { + result = tsDurationReader.readDuration(input, seekPositionHolder); + if (result == Extractor.RESULT_SEEK) { + input.setPosition((int) seekPositionHolder.position); + } + } + assertThat(result).isNotEqualTo(Extractor.RESULT_END_OF_INPUT); + assertThat(tsDurationReader.getDurationUs()).isEqualTo(766); + } + + @Test + public void testReadDuration_midStream_returnsCorrectDuration() + throws IOException, InterruptedException { + FakeExtractorInput input = + new FakeExtractorInput.Builder() + .setData(TestUtil.getByteArray(RuntimeEnvironment.application, "ts/sample.ps")) + .build(); + + input.setPosition(1234); + int result = Extractor.RESULT_CONTINUE; + while (!tsDurationReader.isDurationReadFinished()) { + result = tsDurationReader.readDuration(input, seekPositionHolder); + if (result == Extractor.RESULT_SEEK) { + input.setPosition((int) seekPositionHolder.position); + } + } + assertThat(result).isNotEqualTo(Extractor.RESULT_END_OF_INPUT); + assertThat(tsDurationReader.getDurationUs()).isEqualTo(766); + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java new file mode 100644 index 00000000000..33be3a26fd3 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/extractor/ts/PsExtractorSeekTest.java @@ -0,0 +1,367 @@ +/* + * 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.content.Context; +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.extractor.DefaultExtractorInput; +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.FakeExtractorInput; +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.DataSpec; +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 PsExtractor}. */ +@RunWith(RobolectricTestRunner.class) +public final class PsExtractorSeekTest { + + private static final String PS_FILE_PATH = "ts/elephants_dream.mpg"; + private static final int DURATION_US = 30436333; + private static final int VIDEO_TRACK_ID = 224; + private static final long DELTA_TIMESTAMP_THRESHOLD_US = 500_000L; + private static final Random random = new Random(1234L); + + private FakeExtractorOutput expectedOutput; + private FakeTrackOutput expectedTrackOutput; + + private DefaultDataSource dataSource; + private PositionHolder positionHolder; + private long totalInputLength; + + @Before + public void setUp() throws IOException, InterruptedException { + expectedOutput = new FakeExtractorOutput(); + positionHolder = new PositionHolder(); + extractAllSamplesFromFileToExpectedOutput(RuntimeEnvironment.application, PS_FILE_PATH); + expectedTrackOutput = expectedOutput.trackOutputs.get(VIDEO_TRACK_ID); + + dataSource = + new DefaultDataSourceFactory(RuntimeEnvironment.application, "UserAgent") + .createDataSource(); + totalInputLength = readInputLength(); + } + + @Test + public void testPsExtractorReads_nonSeekTableFile_returnSeekableSeekMap() + throws IOException, InterruptedException { + PsExtractor extractor = new PsExtractor(); + + SeekMap seekMap = extractSeekMapAndTracks(extractor, new FakeExtractorOutput()); + + assertThat(seekMap).isNotNull(); + assertThat(seekMap.getDurationUs()).isEqualTo(DURATION_US); + assertThat(seekMap.isSeekable()).isTrue(); + } + + @Test + public void testHandlePendingSeek_handlesSeekingToPositionInFile_extractsCorrectFrame() + throws IOException, InterruptedException { + PsExtractor extractor = new PsExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMapAndTracks(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(VIDEO_TRACK_ID); + + long targetSeekTimeUs = 987_000; + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainsTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testHandlePendingSeek_handlesSeekToEoF() throws IOException, InterruptedException { + PsExtractor extractor = new PsExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMapAndTracks(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(VIDEO_TRACK_ID); + + long targetSeekTimeUs = seekMap.getDurationUs(); + + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + // Assert that this seek will return a position at end of stream, without any frame. + assertThat(extractedFrameIndex).isEqualTo(-1); + } + + @Test + public void testHandlePendingSeek_handlesSeekingBackward_extractsCorrectFrame() + throws IOException, InterruptedException { + PsExtractor extractor = new PsExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMapAndTracks(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(VIDEO_TRACK_ID); + + long firstSeekTimeUs = 987_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput); + + long targetSeekTimeUs = 0; + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainsTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testHandlePendingSeek_handlesSeekingForward_extractsCorrectFrame() + throws IOException, InterruptedException { + PsExtractor extractor = new PsExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMapAndTracks(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(VIDEO_TRACK_ID); + + long firstSeekTimeUs = 987_000; + seekToTimeUs(extractor, seekMap, firstSeekTimeUs, trackOutput); + + long targetSeekTimeUs = 1_234_000; + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainsTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + + @Test + public void testHandlePendingSeek_handlesRandomSeeks_extractsCorrectFrame() + throws IOException, InterruptedException { + PsExtractor extractor = new PsExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + SeekMap seekMap = extractSeekMapAndTracks(extractor, extractorOutput); + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(VIDEO_TRACK_ID); + + long numSeek = 100; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = random.nextInt(DURATION_US + 1); + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainsTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + } + + @Test + public void testHandlePendingSeek_handlesRandomSeeksAfterReadingFileOnce_extractsCorrectFrame() + throws IOException, InterruptedException { + PsExtractor extractor = new PsExtractor(); + + FakeExtractorOutput extractorOutput = new FakeExtractorOutput(); + readInputFileOnce(extractor, extractorOutput); + SeekMap seekMap = extractorOutput.seekMap; + FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(VIDEO_TRACK_ID); + + long numSeek = 100; + for (long i = 0; i < numSeek; i++) { + long targetSeekTimeUs = random.nextInt(DURATION_US + 1); + int extractedFrameIndex = seekToTimeUs(extractor, seekMap, targetSeekTimeUs, trackOutput); + + assertThat(extractedFrameIndex).isNotEqualTo(-1); + assertFirstFrameAfterSeekContainsTargetSeekTime( + trackOutput, targetSeekTimeUs, extractedFrameIndex); + } + } + + // Internal methods + + private long readInputLength() throws IOException { + DataSpec dataSpec = + new DataSpec(Uri.parse("asset:///" + PS_FILE_PATH), 0, C.LENGTH_UNSET, null); + long totalInputLength = dataSource.open(dataSpec); + Util.closeQuietly(dataSource); + return totalInputLength; + } + + /** + * Seeks to the given seek time and keeps reading from input until we can extract at least one + * frame from the seek position, or until end-of-input is reached. + * + * @return The index of the first extracted frame written to the given {@code trackOutput} after + * the seek is completed, or -1 if the seek is completed without any extracted frame. + */ + private int seekToTimeUs( + PsExtractor psExtractor, SeekMap seekMap, long seekTimeUs, FakeTrackOutput trackOutput) + throws IOException, InterruptedException { + int numSampleBeforeSeek = trackOutput.getSampleCount(); + SeekMap.SeekPoints seekPoints = seekMap.getSeekPoints(seekTimeUs); + + long initialSeekLoadPosition = seekPoints.first.position; + psExtractor.seek(initialSeekLoadPosition, seekTimeUs); + + positionHolder.position = C.POSITION_UNSET; + ExtractorInput extractorInput = getExtractorInputFromPosition(initialSeekLoadPosition); + int extractorReadResult = Extractor.RESULT_CONTINUE; + while (true) { + try { + // Keep reading until we can read at least one frame after seek + while (extractorReadResult == Extractor.RESULT_CONTINUE + && trackOutput.getSampleCount() == numSampleBeforeSeek) { + extractorReadResult = psExtractor.read(extractorInput, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + + if (extractorReadResult == Extractor.RESULT_SEEK) { + extractorInput = getExtractorInputFromPosition(positionHolder.position); + extractorReadResult = Extractor.RESULT_CONTINUE; + } else if (extractorReadResult == Extractor.RESULT_END_OF_INPUT) { + return -1; + } else if (trackOutput.getSampleCount() > numSampleBeforeSeek) { + // First index after seek = num sample before seek. + return numSampleBeforeSeek; + } + } + } + + private SeekMap extractSeekMapAndTracks(PsExtractor extractor, FakeExtractorOutput output) + throws IOException, InterruptedException { + ExtractorInput input = getExtractorInputFromPosition(0); + extractor.init(output); + int readResult = Extractor.RESULT_CONTINUE; + while (true) { + try { + // Keep reading until we can get the seek map + while (readResult == Extractor.RESULT_CONTINUE + && (output.seekMap == null || !output.tracksEnded)) { + readResult = extractor.read(input, positionHolder); + } + } finally { + Util.closeQuietly(dataSource); + } + + if (readResult == Extractor.RESULT_SEEK) { + input = getExtractorInputFromPosition(positionHolder.position); + readResult = Extractor.RESULT_CONTINUE; + } else if (readResult == Extractor.RESULT_END_OF_INPUT) { + throw new IOException("EOF encountered without seekmap"); + } + if (output.seekMap != null) { + return output.seekMap; + } + } + } + + private void readInputFileOnce(PsExtractor extractor, FakeExtractorOutput extractorOutput) + throws IOException, InterruptedException { + extractor.init(extractorOutput); + int readResult = Extractor.RESULT_CONTINUE; + ExtractorInput input = getExtractorInputFromPosition(0); + 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 = getExtractorInputFromPosition(positionHolder.position); + readResult = Extractor.RESULT_CONTINUE; + } + } + } + + private void assertFirstFrameAfterSeekContainsTargetSeekTime( + 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(C.INDEX_UNSET); + + long sampleTimeUs = expectedTrackOutput.getSampleTimeUs(expectedSampleIndex); + if (sampleTimeUs != 0) { + // Assert that the timestamp output for first sample after seek is near the seek point. + // For Ps seeking, unfortunately we can't guarantee exact frame seeking, since PID timestamp + // is not too reliable. + assertThat(Math.abs(outputSampleTimeUs - seekTimeUs)) + .isLessThan(DELTA_TIMESTAMP_THRESHOLD_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(DELTA_TIMESTAMP_THRESHOLD_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 C.INDEX_UNSET; + } + + private ExtractorInput getExtractorInputFromPosition(long position) throws IOException { + DataSpec dataSpec = + new DataSpec( + Uri.parse("asset:///" + PS_FILE_PATH), position, C.LENGTH_UNSET, /* key= */ null); + dataSource.open(dataSpec); + return new DefaultExtractorInput(dataSource, position, totalInputLength); + } + + private void extractAllSamplesFromFileToExpectedOutput(Context context, String fileName) + throws IOException, InterruptedException { + byte[] data = TestUtil.getByteArray(context, fileName); + + PsExtractor extractor = new PsExtractor(); + extractor.init(expectedOutput); + FakeExtractorInput input = new FakeExtractorInput.Builder().setData(data).build(); + + int readResult = Extractor.RESULT_CONTINUE; + while (readResult != Extractor.RESULT_END_OF_INPUT) { + readResult = extractor.read(input, positionHolder); + if (readResult == Extractor.RESULT_SEEK) { + input.setPosition((int) positionHolder.position); + } + } + } +}