Supports seeking for MPEG PS Streams.

This CL adds support for seeking within PS streams by using binary search. For
any seek timestamp, it tries to find the location in the stream where SCR
timestamp is close to the target timestamp, and return this position as the
seek position.

Github: #4476.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=206787691
This commit is contained in:
hoangtc 2018-07-31 10:44:34 -07:00 committed by Oliver Woodman
parent 377314a69f
commit f08ad55892
13 changed files with 917 additions and 59 deletions

View File

@ -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.

View File

@ -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 @@ import java.nio.ByteBuffer;
throws IOException, InterruptedException {
ByteBuffer outputBuffer = outputFrameHolder.byteBuffer;
long searchPosition = input.getPosition();
int searchRangeBytes = getTimestampSearchBytesRange();
decoderJni.reset(searchPosition);
try {
decoderJni.decodeSampleWithBacktrackPosition(
@ -107,13 +105,6 @@ import java.nio.ByteBuffer;
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;
}
}
/**

View File

@ -72,14 +72,6 @@ public abstract class BinarySearchSeeker {
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 abstract class BinarySearchSeeker {
}
}
/**
* 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 abstract class BinarySearchSeeker {
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;
}
}
}

View File

@ -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.
*
* <p>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.
*
* <p>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);
}
}

View File

@ -64,6 +64,10 @@ import java.io.IOException;
return isDurationRead;
}
public TimestampAdjuster getScrTimestampAdjuster() {
return scrTimestampAdjuster;
}
/**
* Reads a PS duration from the input.
*
@ -105,6 +109,25 @@ import java.io.IOException;
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 @@ import java.io.IOException;
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 @@ import java.io.IOException;
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 @@ import java.io.IOException;
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;

View File

@ -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 final class PsExtractor implements Extractor {
@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 final class PsExtractor implements Extractor {
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 final class PsExtractor 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) {
psBinarySearchSeeker =
new PsBinarySearchSeeker(
durationReader.getScrTimestampAdjuster(),
durationReader.getDurationUs(),
inputLength);
output.seekMap(psBinarySearchSeeker.getSeekMap());
} else {
output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs()));
}
}
}

Binary file not shown.

View File

@ -1,5 +1,5 @@
seekMap:
isSeekable = false
isSeekable = true
duration = 766
getPosition(0) = [[timeUs=0, position=0]]
numberOfTracks = 2

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);
}
}
}
}