Add a drop-in replacement for MediaExtractor
in Media3
This change introduces a new class in Media3 `MediaExtractorCompat`, designed to be a drop-in replacement for platform `MediaExtractor`. While not all APIs are currently supported, the core functionality for the most common use cases of `MediaExtractor` is now available. Full API compatibility will be achieved in the future. PiperOrigin-RevId: 643045429
This commit is contained in:
parent
e985603e0a
commit
df5352752f
@ -0,0 +1,727 @@
|
||||
/*
|
||||
* Copyright 2024 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 androidx.media3.exoplayer;
|
||||
|
||||
import static androidx.annotation.VisibleForTesting.NONE;
|
||||
import static androidx.media3.common.util.Assertions.checkNotNull;
|
||||
import static androidx.media3.exoplayer.source.SampleStream.FLAG_OMIT_SAMPLE_DATA;
|
||||
import static androidx.media3.exoplayer.source.SampleStream.FLAG_PEEK;
|
||||
import static androidx.media3.exoplayer.source.SampleStream.FLAG_REQUIRE_FORMAT;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.MediaExtractor;
|
||||
import android.media.MediaFormat;
|
||||
import android.net.Uri;
|
||||
import android.util.SparseArray;
|
||||
import androidx.annotation.IntDef;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.ParserException;
|
||||
import androidx.media3.common.util.Assertions;
|
||||
import androidx.media3.common.util.Log;
|
||||
import androidx.media3.common.util.MediaFormatUtil;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.datasource.DataSource;
|
||||
import androidx.media3.datasource.DataSourceUtil;
|
||||
import androidx.media3.datasource.DataSpec;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
import androidx.media3.decoder.DecoderInputBuffer;
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil;
|
||||
import androidx.media3.exoplayer.source.SampleQueue;
|
||||
import androidx.media3.exoplayer.source.SampleStream.ReadDataResult;
|
||||
import androidx.media3.exoplayer.source.SampleStream.ReadFlags;
|
||||
import androidx.media3.exoplayer.source.UnrecognizedInputFormatException;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.exoplayer.upstream.DefaultAllocator;
|
||||
import androidx.media3.extractor.DefaultExtractorInput;
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory;
|
||||
import androidx.media3.extractor.DummyTrackOutput;
|
||||
import androidx.media3.extractor.Extractor;
|
||||
import androidx.media3.extractor.Extractor.ReadResult;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
import androidx.media3.extractor.ExtractorOutput;
|
||||
import androidx.media3.extractor.ExtractorsFactory;
|
||||
import androidx.media3.extractor.PositionHolder;
|
||||
import androidx.media3.extractor.SeekMap;
|
||||
import androidx.media3.extractor.SeekMap.SeekPoints;
|
||||
import androidx.media3.extractor.SeekPoint;
|
||||
import androidx.media3.extractor.TrackOutput;
|
||||
import androidx.media3.extractor.mp4.Mp4Extractor;
|
||||
import com.google.common.base.Joiner;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.collect.Lists;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
|
||||
|
||||
/**
|
||||
* A drop-in replacement for {@link MediaExtractor} that provides similar functionality, based on
|
||||
* the {@code media3.extractor} logic.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final class MediaExtractorCompat {
|
||||
|
||||
/**
|
||||
* The seeking mode. One of {@link #SEEK_TO_PREVIOUS_SYNC}, {@link #SEEK_TO_NEXT_SYNC}, or {@link
|
||||
* #SEEK_TO_CLOSEST_SYNC}.
|
||||
*/
|
||||
@IntDef({
|
||||
SEEK_TO_PREVIOUS_SYNC,
|
||||
SEEK_TO_NEXT_SYNC,
|
||||
SEEK_TO_CLOSEST_SYNC,
|
||||
})
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface SeekMode {}
|
||||
|
||||
/** See {@link MediaExtractor#SEEK_TO_PREVIOUS_SYNC}. */
|
||||
public static final int SEEK_TO_PREVIOUS_SYNC = MediaExtractor.SEEK_TO_PREVIOUS_SYNC;
|
||||
|
||||
/** See {@link MediaExtractor#SEEK_TO_NEXT_SYNC}. */
|
||||
public static final int SEEK_TO_NEXT_SYNC = MediaExtractor.SEEK_TO_NEXT_SYNC;
|
||||
|
||||
/** See {@link MediaExtractor#SEEK_TO_CLOSEST_SYNC}. */
|
||||
public static final int SEEK_TO_CLOSEST_SYNC = MediaExtractor.SEEK_TO_CLOSEST_SYNC;
|
||||
|
||||
private static final String TAG = "MediaExtractorCompat";
|
||||
|
||||
private final ExtractorsFactory extractorsFactory;
|
||||
private final DataSource.Factory dataSourceFactory;
|
||||
private final PositionHolder positionHolder;
|
||||
private final Allocator allocator;
|
||||
private final ArrayList<MediaExtractorTrack> tracks;
|
||||
private final SparseArray<MediaExtractorSampleQueue> sampleQueues;
|
||||
private final ArrayDeque<Integer> trackIndicesPerSampleInQueuedOrder;
|
||||
private final FormatHolder formatHolder;
|
||||
private final DecoderInputBuffer sampleHolder;
|
||||
private final DecoderInputBuffer noDataBuffer;
|
||||
private final Set<Integer> selectedTrackIndices;
|
||||
|
||||
private boolean hasBeenPrepared;
|
||||
private long offsetInCurrentFile;
|
||||
@Nullable private Extractor currentExtractor;
|
||||
@Nullable private ExtractorInput currentExtractorInput;
|
||||
@Nullable private DataSource currentDataSource;
|
||||
@Nullable private SeekPoint pendingSeek;
|
||||
|
||||
@Nullable private SeekMap seekMap;
|
||||
private boolean tracksEnded;
|
||||
private int upstreamFormatsCount;
|
||||
|
||||
/** Creates a new instance. */
|
||||
public MediaExtractorCompat(Context context) {
|
||||
this(new DefaultExtractorsFactory(), new DefaultDataSource.Factory(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new instance using the given {@link ExtractorsFactory extractorsFactory} to create
|
||||
* the {@link Extractor extractors} to use for obtaining media samples from a DataSource generated
|
||||
* by the given {@link DataSource.Factory dataSourceFactory}.
|
||||
*/
|
||||
public MediaExtractorCompat(
|
||||
ExtractorsFactory extractorsFactory, DataSource.Factory dataSourceFactory) {
|
||||
this.extractorsFactory = extractorsFactory;
|
||||
this.dataSourceFactory = dataSourceFactory;
|
||||
positionHolder = new PositionHolder();
|
||||
allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE);
|
||||
tracks = new ArrayList<>();
|
||||
sampleQueues = new SparseArray<>();
|
||||
trackIndicesPerSampleInQueuedOrder = new ArrayDeque<>();
|
||||
formatHolder = new FormatHolder();
|
||||
sampleHolder = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED);
|
||||
noDataBuffer = DecoderInputBuffer.newNoDataInstance();
|
||||
selectedTrackIndices = new HashSet<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the internal state with the media stream obtained from the given {@code uri} at the
|
||||
* given {@code offset}.
|
||||
*
|
||||
* @param uri The content {@link Uri} to extract from.
|
||||
* @param offset The offset into the file where the data to be extracted starts, in bytes.
|
||||
* @throws IOException If an error occurs while extracting the media.
|
||||
* @throws UnrecognizedInputFormatException If none of the available extractors successfully
|
||||
* sniffs the input.
|
||||
* @throws IllegalStateException If this method is called twice on the same instance.
|
||||
*/
|
||||
public void setDataSource(Uri uri, long offset) throws IOException {
|
||||
// Assert that this instance is not being re-prepared, which is not currently supported.
|
||||
Assertions.checkState(!hasBeenPrepared);
|
||||
hasBeenPrepared = true;
|
||||
offsetInCurrentFile = offset;
|
||||
DataSpec dataSpec = buildDataSpec(uri, /* position= */ offsetInCurrentFile);
|
||||
|
||||
currentDataSource = dataSourceFactory.createDataSource();
|
||||
long length = currentDataSource.open(dataSpec);
|
||||
ExtractorInput currentExtractorInput =
|
||||
new DefaultExtractorInput(currentDataSource, /* position= */ 0, length);
|
||||
Extractor currentExtractor = selectExtractor(currentExtractorInput);
|
||||
currentExtractor.init(new ExtractorOutputImpl());
|
||||
|
||||
boolean preparing = true;
|
||||
Throwable error = null;
|
||||
while (preparing) {
|
||||
int result;
|
||||
try {
|
||||
result = currentExtractor.read(currentExtractorInput, positionHolder);
|
||||
} catch (Exception | OutOfMemoryError e) {
|
||||
// This value is ignored but initializes result to avoid static analysis errors.
|
||||
result = Extractor.RESULT_END_OF_INPUT;
|
||||
error = e;
|
||||
}
|
||||
preparing = !tracksEnded || upstreamFormatsCount < sampleQueues.size() || seekMap == null;
|
||||
if (error != null || (preparing && result == Extractor.RESULT_END_OF_INPUT)) {
|
||||
// TODO(b/178501820): Support files with incomplete track information.
|
||||
release(); // Release resources as soon as possible, in case we are low on memory.
|
||||
String message =
|
||||
error != null
|
||||
? "Exception encountered while parsing input media."
|
||||
: "Reached end of input before preparation completed.";
|
||||
throw ParserException.createForMalformedContainer(message, /* cause= */ error);
|
||||
} else if (result == Extractor.RESULT_SEEK) {
|
||||
currentExtractorInput = reopenCurrentDataSource(positionHolder.position);
|
||||
}
|
||||
}
|
||||
this.currentExtractorInput = currentExtractorInput;
|
||||
this.currentExtractor = currentExtractor;
|
||||
// At this point, we know how many tracks we have, and their format.
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases any resources held by this instance.
|
||||
*
|
||||
* <p>Note: Make sure you call this when you're done to free up any resources instead of relying
|
||||
* on the garbage collector to do this for you at some point in the future.
|
||||
*/
|
||||
public void release() {
|
||||
for (int i = 0; i < sampleQueues.size(); i++) {
|
||||
sampleQueues.valueAt(i).release();
|
||||
}
|
||||
sampleQueues.clear();
|
||||
if (currentExtractor != null) {
|
||||
currentExtractor.release();
|
||||
currentExtractor = null;
|
||||
}
|
||||
currentExtractorInput = null;
|
||||
pendingSeek = null;
|
||||
DataSourceUtil.closeQuietly(currentDataSource);
|
||||
currentDataSource = null;
|
||||
}
|
||||
|
||||
/** Returns the number of tracks found in the data source. */
|
||||
public int getTrackCount() {
|
||||
return tracks.size();
|
||||
}
|
||||
|
||||
/** Returns the track {@link MediaFormat} at the specified {@code trackIndex}. */
|
||||
public MediaFormat getTrackFormat(int trackIndex) {
|
||||
return tracks.get(trackIndex).createDownstreamMediaFormat(formatHolder, noDataBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a track at the specified {@code trackIndex}.
|
||||
*
|
||||
* <p>Subsequent calls to {@link #readSampleData}, {@link #getSampleTrackIndex} and {@link
|
||||
* #getSampleTime} only retrieve information for the subset of tracks selected.
|
||||
*
|
||||
* <p>Note: Selecting the same track multiple times has no effect, the track is only selected
|
||||
* once.
|
||||
*/
|
||||
public void selectTrack(int trackIndex) {
|
||||
selectedTrackIndices.add(trackIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unselects the track at the specified {@code trackIndex}.
|
||||
*
|
||||
* <p>Subsequent calls to {@link #readSampleData}, {@link #getSampleTrackIndex} and {@link
|
||||
* #getSampleTime} only retrieve information for the subset of tracks selected.
|
||||
*/
|
||||
public void unselectTrack(int trackIndex) {
|
||||
selectedTrackIndices.remove(trackIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* All selected tracks seek near the requested {@code timeUs} according to the specified {@code
|
||||
* mode}.
|
||||
*/
|
||||
public void seekTo(long timeUs, @SeekMode int mode) {
|
||||
if (seekMap == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
SeekPoints seekPoints;
|
||||
if (selectedTrackIndices.size() == 1 && currentExtractor instanceof Mp4Extractor) {
|
||||
// Mp4Extractor supports seeking within a specific track. This helps with poorly interleaved
|
||||
// tracks. See b/223910395.
|
||||
seekPoints =
|
||||
((Mp4Extractor) currentExtractor)
|
||||
.getSeekPoints(
|
||||
timeUs, tracks.get(selectedTrackIndices.iterator().next()).getIdOfBackingTrack());
|
||||
} else {
|
||||
seekPoints = seekMap.getSeekPoints(timeUs);
|
||||
}
|
||||
SeekPoint seekPoint;
|
||||
switch (mode) {
|
||||
case SEEK_TO_CLOSEST_SYNC:
|
||||
seekPoint =
|
||||
Math.abs(timeUs - seekPoints.second.timeUs) < Math.abs(timeUs - seekPoints.first.timeUs)
|
||||
? seekPoints.second
|
||||
: seekPoints.first;
|
||||
break;
|
||||
case SEEK_TO_NEXT_SYNC:
|
||||
seekPoint = seekPoints.second;
|
||||
break;
|
||||
case SEEK_TO_PREVIOUS_SYNC:
|
||||
seekPoint = seekPoints.first;
|
||||
break;
|
||||
default:
|
||||
// Should never happen.
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
trackIndicesPerSampleInQueuedOrder.clear();
|
||||
for (int i = 0; i < sampleQueues.size(); i++) {
|
||||
sampleQueues.valueAt(i).reset();
|
||||
}
|
||||
pendingSeek = seekPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances to the next sample. Returns {@code false} if no more sample data is available (i.e.,
|
||||
* end of stream), or {@code true} otherwise.
|
||||
*
|
||||
* <p>Note: When extracting from a local file, the behavior of {@link #advance} and {@link
|
||||
* #readSampleData} is undefined if there are concurrent writes to the same file. This may result
|
||||
* in an unexpected end of stream being signaled.
|
||||
*/
|
||||
public boolean advance() {
|
||||
// Ensure there is a sample to discard.
|
||||
if (!advanceToSampleOrEndOfInput()) {
|
||||
// The end of input has been reached.
|
||||
return false;
|
||||
}
|
||||
skipOneSample();
|
||||
return advanceToSampleOrEndOfInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current encoded sample and stores it in the byte {@code buffer} starting at the
|
||||
* given {@code offset}.
|
||||
*
|
||||
* <p><b>Note:</b>On success, the position and limit of {@code buffer} is updated to point to the
|
||||
* data just read.
|
||||
*
|
||||
* @param buffer the destination byte buffer.
|
||||
* @param offset The offset into the byte buffer at which to write.
|
||||
* @return the sample size, or -1 if no more samples are available.
|
||||
*/
|
||||
public int readSampleData(ByteBuffer buffer, int offset) {
|
||||
if (!advanceToSampleOrEndOfInput()) {
|
||||
return -1;
|
||||
}
|
||||
// The platform media extractor implementation ignores the buffer's input position and limit.
|
||||
buffer.position(offset);
|
||||
buffer.limit(buffer.capacity());
|
||||
sampleHolder.data = buffer;
|
||||
peekNextSelectedTrackSample(sampleHolder, /* omitSampleData= */ false);
|
||||
buffer.flip();
|
||||
buffer.position(offset);
|
||||
sampleHolder.data = null;
|
||||
return buffer.remaining();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the track index the current sample originates from, or -1 if no more samples are
|
||||
* available.
|
||||
*/
|
||||
public int getSampleTrackIndex() {
|
||||
if (!advanceToSampleOrEndOfInput()) {
|
||||
return -1;
|
||||
}
|
||||
return trackIndicesPerSampleInQueuedOrder.peekFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current sample's presentation time in microseconds, or -1 if no more samples are
|
||||
* available.
|
||||
*/
|
||||
public long getSampleTime() {
|
||||
if (!advanceToSampleOrEndOfInput()) {
|
||||
return -1;
|
||||
}
|
||||
peekNextSelectedTrackSample(noDataBuffer, /* omitSampleData= */ true);
|
||||
return noDataBuffer.timeUs;
|
||||
}
|
||||
|
||||
/** Returns the current sample's flags. */
|
||||
public int getSampleFlags() {
|
||||
if (!advanceToSampleOrEndOfInput()) {
|
||||
return -1;
|
||||
}
|
||||
peekNextSelectedTrackSample(noDataBuffer, /* omitSampleData= */ true);
|
||||
int flags = 0;
|
||||
flags |= noDataBuffer.isEncrypted() ? MediaExtractor.SAMPLE_FLAG_ENCRYPTED : 0;
|
||||
flags |= noDataBuffer.isKeyFrame() ? MediaExtractor.SAMPLE_FLAG_SYNC : 0;
|
||||
return flags;
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = NONE)
|
||||
public Allocator getAllocator() {
|
||||
return allocator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peeks a sample from the front of the given {@link SampleQueue}, discarding the downstream
|
||||
* {@link Format} first, if necessary.
|
||||
*
|
||||
* @param decoderInputBuffer The buffer to populate.
|
||||
* @param omitSampleData Whether to omit the sample's data.
|
||||
* @throws IllegalStateException If a sample is not peeked as a result of calling this method.
|
||||
*/
|
||||
private void peekNextSelectedTrackSample(
|
||||
DecoderInputBuffer decoderInputBuffer, boolean omitSampleData) {
|
||||
MediaExtractorTrack trackOfSample =
|
||||
tracks.get(checkNotNull(trackIndicesPerSampleInQueuedOrder.peekFirst()));
|
||||
SampleQueue sampleQueue = trackOfSample.sampleQueue;
|
||||
@ReadFlags int readFlags = FLAG_PEEK | (omitSampleData ? FLAG_OMIT_SAMPLE_DATA : 0);
|
||||
@ReadDataResult
|
||||
int result =
|
||||
sampleQueue.read(formatHolder, decoderInputBuffer, readFlags, /* loadingFinished= */ false);
|
||||
if (result == C.RESULT_FORMAT_READ) {
|
||||
// We've consumed a downstream format. Now consume the actual sample.
|
||||
result =
|
||||
sampleQueue.read(
|
||||
formatHolder, decoderInputBuffer, readFlags, /* loadingFinished= */ false);
|
||||
}
|
||||
formatHolder.clear();
|
||||
// Additional logging is added to debug b/241321832.
|
||||
if (result != C.RESULT_BUFFER_READ) {
|
||||
// This method should only be called when there is a sample available for reading.
|
||||
throw new IllegalStateException(
|
||||
Util.formatInvariant(
|
||||
"Sample read result: %s\n"
|
||||
+ "Track sample: %s\n"
|
||||
+ "TrackIndicesPerSampleInQueuedOrder: %s\n"
|
||||
+ "Tracks added: %s\n",
|
||||
result, trackOfSample, trackIndicesPerSampleInQueuedOrder, tracks));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the extractor to use for extracting samples from the given {@code input}.
|
||||
*
|
||||
* @throws IOException If an error occurs while extracting the media.
|
||||
* @throws UnrecognizedInputFormatException If none of the available extractors successfully
|
||||
* sniffs the input.
|
||||
*/
|
||||
private Extractor selectExtractor(ExtractorInput input) throws IOException {
|
||||
Extractor[] extractors = extractorsFactory.createExtractors();
|
||||
Extractor result = null;
|
||||
for (Extractor extractor : extractors) {
|
||||
try {
|
||||
if (extractor.sniff(input)) {
|
||||
result = extractor;
|
||||
break;
|
||||
}
|
||||
} catch (EOFException e) {
|
||||
// We reached the end of input without recognizing the input format. Do nothing to let the
|
||||
// next extractor sniff the content.
|
||||
} finally {
|
||||
input.resetPeekPosition();
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
throw new UnrecognizedInputFormatException(
|
||||
"None of the available extractors ("
|
||||
+ Joiner.on(", ")
|
||||
.join(
|
||||
Lists.transform(
|
||||
ImmutableList.copyOf(extractors),
|
||||
extractor ->
|
||||
extractor.getUnderlyingImplementation().getClass().getSimpleName()))
|
||||
+ ") could read the stream.",
|
||||
checkNotNull(checkNotNull(currentDataSource).getUri()),
|
||||
ImmutableList.of());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances extraction until there is a queued sample from a selected track, or the end of the
|
||||
* input is found.
|
||||
*
|
||||
* <p>Handles I/O errors (for example, network connection loss) and parsing errors (for example, a
|
||||
* truncated file) in the same way as {@link MediaExtractor}, treating them as the end of input.
|
||||
*
|
||||
* @return Whether a sample from a selected track is available.
|
||||
*/
|
||||
@EnsuresNonNullIf(expression = "trackIndicesPerSampleInQueuedOrder.peekFirst()", result = true)
|
||||
private boolean advanceToSampleOrEndOfInput() {
|
||||
try {
|
||||
maybeResolvePendingSeek();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Treating exception as the end of input.", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean seenEndOfInput = false;
|
||||
while (true) {
|
||||
if (!trackIndicesPerSampleInQueuedOrder.isEmpty()) {
|
||||
// By default, tracks are unselected.
|
||||
if (selectedTrackIndices.contains(trackIndicesPerSampleInQueuedOrder.peekFirst())) {
|
||||
return true;
|
||||
} else {
|
||||
// There is a queued sample, but its track is unselected. We skip the sample.
|
||||
skipOneSample();
|
||||
}
|
||||
} else if (!seenEndOfInput) {
|
||||
// There are no queued samples for the selected tracks, but we can feed more data to the
|
||||
// extractor and see if more samples are produced.
|
||||
try {
|
||||
@ReadResult
|
||||
int result =
|
||||
checkNotNull(currentExtractor)
|
||||
.read(checkNotNull(currentExtractorInput), positionHolder);
|
||||
if (result == Extractor.RESULT_END_OF_INPUT) {
|
||||
seenEndOfInput = true;
|
||||
} else if (result == Extractor.RESULT_SEEK) {
|
||||
this.currentExtractorInput = reopenCurrentDataSource(positionHolder.position);
|
||||
}
|
||||
} catch (Exception | OutOfMemoryError e) {
|
||||
Log.w(TAG, "Treating exception as the end of input.", e);
|
||||
seenEndOfInput = true;
|
||||
}
|
||||
} else {
|
||||
// No queued samples for selected tracks, and we've parsed all the file. Nothing else to do.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void skipOneSample() {
|
||||
int trackIndex = trackIndicesPerSampleInQueuedOrder.removeFirst();
|
||||
MediaExtractorTrack track = tracks.get(trackIndex);
|
||||
if (!track.isCompatibilityTrack) {
|
||||
// We also need to skip the sample data.
|
||||
track.discardFrontSample();
|
||||
}
|
||||
}
|
||||
|
||||
private ExtractorInput reopenCurrentDataSource(long newPositionInStream) throws IOException {
|
||||
DataSource currentDataSource = checkNotNull(this.currentDataSource);
|
||||
Uri currentUri = checkNotNull(currentDataSource.getUri());
|
||||
DataSourceUtil.closeQuietly(currentDataSource);
|
||||
long length =
|
||||
currentDataSource.open(
|
||||
buildDataSpec(currentUri, offsetInCurrentFile + newPositionInStream));
|
||||
if (length != C.LENGTH_UNSET) {
|
||||
length += newPositionInStream;
|
||||
}
|
||||
return new DefaultExtractorInput(currentDataSource, newPositionInStream, length);
|
||||
}
|
||||
|
||||
private void onSampleQueueFormatInitialized(
|
||||
MediaExtractorSampleQueue mediaExtractorSampleQueue, Format newUpstreamFormat) {
|
||||
upstreamFormatsCount++;
|
||||
mediaExtractorSampleQueue.setMainTrackIndex(tracks.size());
|
||||
tracks.add(
|
||||
new MediaExtractorTrack(
|
||||
mediaExtractorSampleQueue,
|
||||
/* isCompatibilityTrack= */ false,
|
||||
/* compatibilityTrackMimeType= */ null));
|
||||
@Nullable
|
||||
String compatibilityTrackMimeType =
|
||||
MediaCodecUtil.getAlternativeCodecMimeType(newUpstreamFormat);
|
||||
if (compatibilityTrackMimeType != null) {
|
||||
mediaExtractorSampleQueue.setCompatibilityTrackIndex(tracks.size());
|
||||
tracks.add(
|
||||
new MediaExtractorTrack(
|
||||
mediaExtractorSampleQueue,
|
||||
/* isCompatibilityTrack= */ true,
|
||||
compatibilityTrackMimeType));
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeResolvePendingSeek() throws IOException {
|
||||
if (this.pendingSeek == null) {
|
||||
return; // Nothing to do.
|
||||
}
|
||||
SeekPoint pendingSeek = checkNotNull(this.pendingSeek);
|
||||
checkNotNull(currentExtractor).seek(pendingSeek.position, pendingSeek.timeUs);
|
||||
this.currentExtractorInput = reopenCurrentDataSource(pendingSeek.position);
|
||||
this.pendingSeek = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link DataSpec} with the given data.
|
||||
*
|
||||
* <p>The created {@link DataSpec} disables caching if the content length cannot be resolved,
|
||||
* since this is indicative of a progressive live stream.
|
||||
*/
|
||||
private static DataSpec buildDataSpec(Uri uri, long position) {
|
||||
return new DataSpec.Builder()
|
||||
.setUri(uri)
|
||||
.setPosition(position)
|
||||
.setFlags(
|
||||
DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN | DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION)
|
||||
.build();
|
||||
}
|
||||
|
||||
private final class ExtractorOutputImpl implements ExtractorOutput {
|
||||
|
||||
@Override
|
||||
public TrackOutput track(int id, int type) {
|
||||
MediaExtractorSampleQueue sampleQueue = sampleQueues.get(id);
|
||||
if (sampleQueue != null) {
|
||||
// This track has already been declared. We return the sample queue that corresponds to this
|
||||
// id.
|
||||
return sampleQueue;
|
||||
}
|
||||
if (tracksEnded) {
|
||||
// The id is new and the extractor has ended the tracks. Discard.
|
||||
return new DummyTrackOutput();
|
||||
}
|
||||
|
||||
sampleQueue = new MediaExtractorSampleQueue(allocator, id);
|
||||
sampleQueues.put(id, sampleQueue);
|
||||
return sampleQueue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endTracks() {
|
||||
tracksEnded = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seekMap(SeekMap seekMap) {
|
||||
MediaExtractorCompat.this.seekMap = seekMap;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MediaExtractorTrack {
|
||||
|
||||
public final MediaExtractorSampleQueue sampleQueue;
|
||||
public final boolean isCompatibilityTrack;
|
||||
@Nullable public final String compatibilityTrackMimeType;
|
||||
|
||||
private MediaExtractorTrack(
|
||||
MediaExtractorSampleQueue sampleQueue,
|
||||
boolean isCompatibilityTrack,
|
||||
@Nullable String compatibilityTrackMimeType) {
|
||||
this.sampleQueue = sampleQueue;
|
||||
this.isCompatibilityTrack = isCompatibilityTrack;
|
||||
this.compatibilityTrackMimeType = compatibilityTrackMimeType;
|
||||
}
|
||||
|
||||
public MediaFormat createDownstreamMediaFormat(
|
||||
FormatHolder scratchFormatHolder, DecoderInputBuffer scratchNoDataDecoderInputBuffer) {
|
||||
scratchFormatHolder.clear();
|
||||
sampleQueue.read(
|
||||
scratchFormatHolder,
|
||||
scratchNoDataDecoderInputBuffer,
|
||||
FLAG_REQUIRE_FORMAT,
|
||||
/* loadingFinished= */ false);
|
||||
Format result = checkNotNull(scratchFormatHolder.format);
|
||||
MediaFormat mediaFormatResult = MediaFormatUtil.createMediaFormatFromFormat(result);
|
||||
scratchFormatHolder.clear();
|
||||
if (compatibilityTrackMimeType != null) {
|
||||
mediaFormatResult.setString(MediaFormat.KEY_CODECS_STRING, null);
|
||||
mediaFormatResult.setString(MediaFormat.KEY_MIME, compatibilityTrackMimeType);
|
||||
}
|
||||
return mediaFormatResult;
|
||||
}
|
||||
|
||||
public void discardFrontSample() {
|
||||
sampleQueue.skip(/* count= */ 1);
|
||||
sampleQueue.discardToRead();
|
||||
}
|
||||
|
||||
public int getIdOfBackingTrack() {
|
||||
return sampleQueue.trackId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format(
|
||||
"MediaExtractorSampleQueue: %s, isCompatibilityTrack: %s, compatibilityTrackMimeType: %s",
|
||||
sampleQueue, isCompatibilityTrack, compatibilityTrackMimeType);
|
||||
}
|
||||
}
|
||||
|
||||
private final class MediaExtractorSampleQueue extends SampleQueue {
|
||||
|
||||
public final int trackId;
|
||||
private int mainTrackIndex;
|
||||
private int compatibilityTrackIndex;
|
||||
|
||||
public MediaExtractorSampleQueue(Allocator allocator, int trackId) {
|
||||
// We do not need the sample queue to acquire keys for encrypted samples, so we pass null
|
||||
// values for DRM-related arguments.
|
||||
super(allocator, /* drmSessionManager= */ null, /* drmEventDispatcher= */ null);
|
||||
this.trackId = trackId;
|
||||
mainTrackIndex = C.INDEX_UNSET;
|
||||
compatibilityTrackIndex = C.INDEX_UNSET;
|
||||
}
|
||||
|
||||
public void setMainTrackIndex(int mainTrackIndex) {
|
||||
this.mainTrackIndex = mainTrackIndex;
|
||||
}
|
||||
|
||||
public void setCompatibilityTrackIndex(int compatibilityTrackIndex) {
|
||||
this.compatibilityTrackIndex = compatibilityTrackIndex;
|
||||
}
|
||||
|
||||
// SampleQueue implementation.
|
||||
|
||||
@Override
|
||||
public Format getAdjustedUpstreamFormat(Format format) {
|
||||
if (getUpstreamFormat() == null) {
|
||||
onSampleQueueFormatInitialized(this, format);
|
||||
}
|
||||
return super.getAdjustedUpstreamFormat(format);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sampleMetadata(
|
||||
long timeUs, int flags, int size, int offset, @Nullable CryptoData cryptoData) {
|
||||
// Disable BUFFER_FLAG_LAST_SAMPLE to prevent the sample queue from ignoring
|
||||
// FLAG_REQUIRE_FORMAT. See b/191518632.
|
||||
flags &= ~C.BUFFER_FLAG_LAST_SAMPLE;
|
||||
if (compatibilityTrackIndex != C.INDEX_UNSET) {
|
||||
trackIndicesPerSampleInQueuedOrder.addLast(compatibilityTrackIndex);
|
||||
}
|
||||
Assertions.checkState(mainTrackIndex != C.INDEX_UNSET);
|
||||
trackIndicesPerSampleInQueuedOrder.addLast(mainTrackIndex);
|
||||
super.sampleMetadata(timeUs, flags, size, offset, cryptoData);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format(
|
||||
"trackId: %s, mainTrackIndex: %s, compatibilityTrackIndex: %s",
|
||||
trackId, mainTrackIndex, compatibilityTrackIndex);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,602 @@
|
||||
/*
|
||||
* Copyright 2024 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 androidx.media3.exoplayer;
|
||||
|
||||
import static com.google.common.truth.Truth.assertThat;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
import android.media.MediaFormat;
|
||||
import android.net.Uri;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.ParserException;
|
||||
import androidx.media3.common.util.ParsableByteArray;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
import androidx.media3.exoplayer.upstream.Allocator;
|
||||
import androidx.media3.extractor.Extractor;
|
||||
import androidx.media3.extractor.ExtractorInput;
|
||||
import androidx.media3.extractor.ExtractorOutput;
|
||||
import androidx.media3.extractor.ExtractorsFactory;
|
||||
import androidx.media3.extractor.PositionHolder;
|
||||
import androidx.media3.extractor.SeekMap;
|
||||
import androidx.media3.extractor.SeekMap.SeekPoints;
|
||||
import androidx.media3.extractor.SeekPoint;
|
||||
import androidx.media3.extractor.TrackOutput;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/** Tests for {@link MediaExtractorCompat}. */
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class MediaExtractorCompatTest {
|
||||
|
||||
/**
|
||||
* Placeholder data URI which saves us from mocking the data source which MediaExtractorCompat
|
||||
* uses.
|
||||
*
|
||||
* <p>Note: The created data source will be opened, but no data will be read from it, so the
|
||||
* contents are irrelevant.
|
||||
*/
|
||||
private static final Uri PLACEHOLDER_URI = Uri.parse("data:,0123456789");
|
||||
|
||||
private static final Format PLACEHOLDER_FORMAT_AUDIO =
|
||||
new Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AAC).build();
|
||||
private static final Format PLACEHOLDER_FORMAT_VIDEO =
|
||||
new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build();
|
||||
|
||||
private FakeExtractor fakeExtractor;
|
||||
private SeekMap fakeSeekMap;
|
||||
private MediaExtractorCompat mediaExtractorCompat;
|
||||
private ExtractorOutput extractorOutput;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
fakeExtractor = new FakeExtractor();
|
||||
fakeSeekMap = new FakeSeekMap();
|
||||
ExtractorsFactory factory = () -> new Extractor[] {fakeExtractor};
|
||||
mediaExtractorCompat =
|
||||
new MediaExtractorCompat(
|
||||
factory, new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setDataSource_forEmptyContainerFile_producesZeroTracks() throws IOException {
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
extractorOutput.endTracks();
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setDataSource_doesNotPerformMoreReadsThanNecessary() throws IOException {
|
||||
TrackOutput[] trackOutputs = new TrackOutput[2];
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
trackOutputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
trackOutputs[1] = extractorOutput.track(/* id= */ 1, C.TRACK_TYPE_VIDEO);
|
||||
extractorOutput.endTracks();
|
||||
// Should be ignored. Tracks have ended and the id doesn't exist.
|
||||
extractorOutput.track(/* id= */ 2, C.TRACK_TYPE_TEXT);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
trackOutputs[0].format(PLACEHOLDER_FORMAT_AUDIO);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
// After this read call, the extractor should have enough to finish preparation:
|
||||
// formats, seek map, and the tracks have ended.
|
||||
trackOutputs[1].format(PLACEHOLDER_FORMAT_VIDEO);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(MediaExtractorCompatTest::assertionFailureReadAction);
|
||||
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
|
||||
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2);
|
||||
assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME))
|
||||
.isEqualTo(PLACEHOLDER_FORMAT_AUDIO.sampleMimeType);
|
||||
assertThat(mediaExtractorCompat.getTrackFormat(1).getString(MediaFormat.KEY_MIME))
|
||||
.isEqualTo(PLACEHOLDER_FORMAT_VIDEO.sampleMimeType);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setDataSource_withTrackIdReuse_reusesTrackOutput() throws IOException {
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
TrackOutput trackOutput1 = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO);
|
||||
TrackOutput trackOutput2 = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO);
|
||||
assertThat(trackOutput1).isSameInstanceAs(trackOutput2);
|
||||
trackOutput1.format(PLACEHOLDER_FORMAT_AUDIO);
|
||||
extractorOutput.endTracks();
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readSampleData_forInterleavedInputSamples_producesExpectedSamples()
|
||||
throws IOException {
|
||||
TrackOutput[] outputs = new TrackOutput[2];
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
outputs[0].format(PLACEHOLDER_FORMAT_VIDEO);
|
||||
outputs[1] = extractorOutput.track(/* id= */ 1, C.TRACK_TYPE_AUDIO);
|
||||
outputs[1].format(PLACEHOLDER_FORMAT_AUDIO);
|
||||
extractorOutput.endTracks();
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputSampleData(outputs[0], /* sampleData...= */ (byte) 1, (byte) 2, (byte) 3);
|
||||
outputSampleData(outputs[1], /* sampleData...= */ (byte) 4, (byte) 5, (byte) 6);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputSample(outputs[0], /* timeUs= */ 4, /* size= */ 1, /* offset= */ 2);
|
||||
outputSample(outputs[1], /* timeUs= */ 3, /* size= */ 1, /* offset= */ 2);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputSample(outputs[0], /* timeUs= */ 2, /* size= */ 2, /* offset= */ 0);
|
||||
outputSample(outputs[1], /* timeUs= */ 1, /* size= */ 2, /* offset= */ 0);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(MediaExtractorCompatTest::assertionFailureReadAction);
|
||||
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
|
||||
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2);
|
||||
mediaExtractorCompat.selectTrack(0);
|
||||
mediaExtractorCompat.selectTrack(1);
|
||||
assertReadSample(/* trackIndex= */ 0, /* timeUs= */ 4, /* sampleData...= */ (byte) 1);
|
||||
mediaExtractorCompat.advance();
|
||||
assertReadSample(/* trackIndex= */ 1, /* timeUs= */ 3, /* sampleData...= */ (byte) 4);
|
||||
mediaExtractorCompat.advance();
|
||||
assertReadSample(/* trackIndex= */ 0, /* timeUs= */ 2, /* sampleData...= */ (byte) 2, (byte) 3);
|
||||
mediaExtractorCompat.advance();
|
||||
assertReadSample(/* trackIndex= */ 1, /* timeUs= */ 1, /* sampleData...= */ (byte) 5, (byte) 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getTrackFormat_atEndOfStreamWithFlagLastSample_producesATrackFormat()
|
||||
throws IOException {
|
||||
// This is a regression test for b/191518632.
|
||||
TrackOutput[] outputs = new TrackOutput[1];
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
outputs[0].format(PLACEHOLDER_FORMAT_VIDEO);
|
||||
extractorOutput.endTracks();
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputSampleData(outputs[0], /* sampleData...= */ (byte) 1, (byte) 2, (byte) 3);
|
||||
outputs[0].sampleMetadata(
|
||||
/* timeUs= */ 0,
|
||||
/* flags= */ C.BUFFER_FLAG_KEY_FRAME | C.BUFFER_FLAG_LAST_SAMPLE,
|
||||
/* size= */ 3,
|
||||
/* offset= */ 0,
|
||||
/* cryptoData= */ null);
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
|
||||
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(1);
|
||||
mediaExtractorCompat.selectTrack(0);
|
||||
mediaExtractorCompat.advance();
|
||||
// After skipping the only sample, there should be none left, and getSampleTime should return
|
||||
// -1.
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
|
||||
assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME))
|
||||
.isEqualTo(PLACEHOLDER_FORMAT_VIDEO.sampleMimeType);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setDataSource_withOutOfMemoryError_wrapsError() throws IOException {
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
throw new OutOfMemoryError();
|
||||
});
|
||||
ParserException exception =
|
||||
assertThrows(
|
||||
ParserException.class,
|
||||
() -> mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0));
|
||||
assertThat(exception).hasCauseThat().isInstanceOf(OutOfMemoryError.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getSampleTime_withOutOfMemoryError_producesEndOfInput() throws IOException {
|
||||
// This boolean guarantees that this test remains useful. The throwing read action is being
|
||||
// called as a result of an implementation detail (trying to parse a container file with no
|
||||
// tracks) that could change in the future. Counting on this implementation detail simplifies
|
||||
// the test.
|
||||
AtomicBoolean outOfMemoryErrorWasThrown = new AtomicBoolean(false);
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
extractorOutput.endTracks();
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outOfMemoryErrorWasThrown.set(true);
|
||||
throw new OutOfMemoryError();
|
||||
});
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
|
||||
assertThat(outOfMemoryErrorWasThrown.get()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void setDataSource_withDolbyVision_generatesCompatibilityTrack() throws IOException {
|
||||
TrackOutput[] outputs = new TrackOutput[1];
|
||||
byte[] sampleData = new byte[] {(byte) 1, (byte) 2, (byte) 3};
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputs[0] = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
extractorOutput.endTracks();
|
||||
outputs[0].format(
|
||||
new Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
|
||||
.setCodecs("hev1.08.10")
|
||||
.build());
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
outputSampleData(outputs[0], sampleData);
|
||||
outputSample(outputs[0], /* timeUs= */ 7, /* size= */ 3, /* offset= */ 0);
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
assertThat(mediaExtractorCompat.getTrackCount()).isEqualTo(2);
|
||||
assertThat(mediaExtractorCompat.getTrackFormat(0).getString(MediaFormat.KEY_MIME))
|
||||
.isEqualTo(MimeTypes.VIDEO_DOLBY_VISION);
|
||||
assertThat(mediaExtractorCompat.getTrackFormat(1).getString(MediaFormat.KEY_MIME))
|
||||
.isEqualTo(MimeTypes.VIDEO_H265);
|
||||
ByteBuffer scratchBuffer = ByteBuffer.allocate(3);
|
||||
|
||||
mediaExtractorCompat.selectTrack(0);
|
||||
mediaExtractorCompat.selectTrack(1);
|
||||
|
||||
assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(1);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
|
||||
assertThat(mediaExtractorCompat.readSampleData(scratchBuffer, /* offset= */ 0)).isEqualTo(3);
|
||||
assertThat(scratchBuffer.array()).isEqualTo(sampleData);
|
||||
|
||||
assertThat(mediaExtractorCompat.advance()).isTrue();
|
||||
|
||||
assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(0);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
|
||||
assertThat(mediaExtractorCompat.readSampleData(scratchBuffer, /* offset= */ 0)).isEqualTo(3);
|
||||
assertThat(scratchBuffer.array()).isEqualTo(sampleData);
|
||||
|
||||
assertThat(mediaExtractorCompat.advance()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seekTo_resetsSampleQueues() throws IOException {
|
||||
fakeExtractor.setSeekStrategy(fakeExtractor::rewindReadActions);
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
extractorOutput.endTracks();
|
||||
output.format(PLACEHOLDER_FORMAT_VIDEO);
|
||||
outputSampleData(output, /* sampleData...= */ (byte) 0);
|
||||
outputSample(output, /* timeUs= */ 7, /* size= */ 1, /* offset= */ 0);
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
|
||||
// Calling getSampleTime forces the extractor to populate the sample queues with the first
|
||||
// sample. As a result, to pass this test, the tested implementation must clear the sample
|
||||
// queues when seekTo is called.
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
|
||||
mediaExtractorCompat.seekTo(/* timeUs= */ 0, MediaExtractorCompat.SEEK_TO_PREVIOUS_SYNC);
|
||||
// Test the same sample (and only that sample) is read after the seek to the start.
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
|
||||
assertThat(mediaExtractorCompat.advance()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readSampleData_usesOffsetArgumentInsteadOfBufferPosition() throws IOException {
|
||||
byte[] sampleData =
|
||||
new byte[] {
|
||||
(byte) 1, (byte) 2, (byte) 3, (byte) 4, (byte) 5, (byte) 6, (byte) 7, (byte) 8, (byte) 9
|
||||
};
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
extractorOutput.endTracks();
|
||||
output.format(PLACEHOLDER_FORMAT_VIDEO);
|
||||
outputSampleData(output, sampleData);
|
||||
outputSample(output, /* timeUs= */ 1, /* size= */ 2, /* offset= */ 7);
|
||||
outputSample(output, /* timeUs= */ 2, /* size= */ 3, /* offset= */ 4);
|
||||
outputSample(output, /* timeUs= */ 3, /* size= */ 4, /* offset= */ 0);
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
|
||||
ByteBuffer byteBuffer = ByteBuffer.allocate(9);
|
||||
// Set the position to the limit to test that the position is ignored, like the platform media
|
||||
// extractor implementation does.
|
||||
byteBuffer.position(byteBuffer.limit());
|
||||
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(1);
|
||||
assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 0)).isEqualTo(2);
|
||||
assertThat(byteBuffer.position()).isEqualTo(0);
|
||||
assertThat(byteBuffer.limit()).isEqualTo(2);
|
||||
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(2);
|
||||
assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 2)).isEqualTo(3);
|
||||
assertThat(byteBuffer.position()).isEqualTo(2);
|
||||
assertThat(byteBuffer.limit()).isEqualTo(5);
|
||||
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(3);
|
||||
assertThat(mediaExtractorCompat.readSampleData(byteBuffer, /* offset= */ 5)).isEqualTo(4);
|
||||
assertThat(byteBuffer.position()).isEqualTo(5);
|
||||
assertThat(byteBuffer.limit()).isEqualTo(9);
|
||||
|
||||
assertThat(byteBuffer.array()).isEqualTo(sampleData);
|
||||
assertThat(mediaExtractorCompat.advance()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void advance_releasesMemoryOfSkippedSamples() throws IOException {
|
||||
// This is a regression test for b/209801945.
|
||||
Allocator allocator = mediaExtractorCompat.getAllocator();
|
||||
int individualAllocationSize = allocator.getIndividualAllocationLength();
|
||||
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
extractorOutput.endTracks();
|
||||
output.format(PLACEHOLDER_FORMAT_VIDEO);
|
||||
outputSampleData(output, new byte[individualAllocationSize]);
|
||||
outputSample(
|
||||
output, /* timeUs= */ 1, /* size= */ individualAllocationSize, /* offset= */ 0);
|
||||
return Extractor.RESULT_CONTINUE;
|
||||
});
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
outputSampleData(output, new byte[individualAllocationSize * 2]);
|
||||
outputSample(
|
||||
output,
|
||||
/* timeUs= */ 2,
|
||||
/* size= */ individualAllocationSize,
|
||||
/* offset= */ individualAllocationSize);
|
||||
outputSample(
|
||||
output, /* timeUs= */ 3, /* size= */ individualAllocationSize, /* offset= */ 0);
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(0);
|
||||
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
|
||||
|
||||
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize);
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize * 2);
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(individualAllocationSize);
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(allocator.getTotalBytesAllocated()).isEqualTo(0);
|
||||
}
|
||||
|
||||
// Test for b/223910395.
|
||||
@Test
|
||||
public void seek_withUninterleavedFile_seeksToTheRightPosition() throws IOException {
|
||||
// We don't use the global mediaExtractorCompat because we want to use a real extractor in this
|
||||
// case, which is the Mp4 extractor.
|
||||
MediaExtractorCompat mediaExtractorCompat =
|
||||
new MediaExtractorCompat(ApplicationProvider.getApplicationContext());
|
||||
// The asset is an uninterleaved mp4 file.
|
||||
mediaExtractorCompat.setDataSource(
|
||||
Uri.parse("asset:///media/mp4/mv_with_2_top_shots.mp4"), /* offset= */ 0);
|
||||
mediaExtractorCompat.selectTrack(1);
|
||||
mediaExtractorCompat.seekTo(1773911, MediaExtractorCompat.SEEK_TO_PREVIOUS_SYNC);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(1773911);
|
||||
}
|
||||
|
||||
// Test for b/233756471.
|
||||
@Test
|
||||
public void seek_withException_producesEndOfInput() throws IOException {
|
||||
Function<Long, SeekPoints> seekPointsFunction =
|
||||
(timesUs) -> {
|
||||
// For the mid seek point we use an invalid position which will cause an IOException. We
|
||||
// expect that exception to be treated as the end of input.
|
||||
SeekPoint midSeekPoint = new SeekPoint(/* timeUs= */ 14, /* position= */ 1000);
|
||||
return timesUs < 14
|
||||
? new SeekPoints(SeekPoint.START, midSeekPoint)
|
||||
: new SeekPoints(midSeekPoint);
|
||||
};
|
||||
|
||||
fakeExtractor.setSeekStrategy(fakeExtractor::rewindReadActions);
|
||||
fakeExtractor.addReadAction(
|
||||
(input, seekPosition) -> {
|
||||
TrackOutput output = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_VIDEO);
|
||||
extractorOutput.endTracks();
|
||||
output.format(PLACEHOLDER_FORMAT_VIDEO);
|
||||
extractorOutput.seekMap(new FakeSeekMap(/* durationUs= */ 28, seekPointsFunction));
|
||||
outputSampleData(output, /* sampleData...= */ (byte) 0, (byte) 1, (byte) 2);
|
||||
outputSample(output, /* timeUs= */ 7, /* size= */ 1, /* offset= */ 2);
|
||||
outputSample(output, /* timeUs= */ 14, /* size= */ 1, /* offset= */ 1);
|
||||
outputSample(output, /* timeUs= */ 21, /* size= */ 1, /* offset= */ 0);
|
||||
return Extractor.RESULT_END_OF_INPUT;
|
||||
});
|
||||
mediaExtractorCompat.setDataSource(PLACEHOLDER_URI, /* offset= */ 0);
|
||||
mediaExtractorCompat.selectTrack(/* trackIndex= */ 0);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(14);
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(21);
|
||||
mediaExtractorCompat.advance();
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
|
||||
|
||||
// This seek will cause the target position to be invalid, causing an IOException which should
|
||||
// be treated as the end of input.
|
||||
mediaExtractorCompat.seekTo(/* timeUs= */ 14, MediaExtractorCompat.SEEK_TO_CLOSEST_SYNC);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(-1);
|
||||
|
||||
// This seek should go to position 0, which should be handled correctly again.
|
||||
mediaExtractorCompat.seekTo(/* timeUs= */ 0, MediaExtractorCompat.SEEK_TO_CLOSEST_SYNC);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(7);
|
||||
}
|
||||
|
||||
// Internal methods.
|
||||
|
||||
private void assertReadSample(int trackIndex, long timeUs, byte... sampleData) {
|
||||
assertThat(mediaExtractorCompat.getSampleTrackIndex()).isEqualTo(trackIndex);
|
||||
assertThat(mediaExtractorCompat.getSampleTime()).isEqualTo(timeUs);
|
||||
ByteBuffer buffer = ByteBuffer.allocate(100);
|
||||
assertThat(mediaExtractorCompat.readSampleData(buffer, /* offset= */ 0))
|
||||
.isEqualTo(sampleData.length);
|
||||
for (int i = 0; i < buffer.remaining(); i++) {
|
||||
assertThat(buffer.get()).isEqualTo(sampleData[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void outputSampleData(TrackOutput trackOutput, byte... sampleData) {
|
||||
trackOutput.sampleData(new ParsableByteArray(sampleData), sampleData.length);
|
||||
}
|
||||
|
||||
private static void outputSample(TrackOutput trackOutput, long timeUs, int size, int offset) {
|
||||
trackOutput.sampleMetadata(
|
||||
timeUs, C.BUFFER_FLAG_KEY_FRAME, size, offset, /* cryptoData= */ null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read action to verify that {@link MediaExtractorCompat} does not read more data than expected.
|
||||
*/
|
||||
private static int assertionFailureReadAction(ExtractorInput input, PositionHolder holder) {
|
||||
throw new AssertionError("ExoPlayerBackedMediaExtractorProxy read more data than needed.");
|
||||
}
|
||||
|
||||
// Internal classes.
|
||||
|
||||
private static class FakeSeekMap implements SeekMap {
|
||||
|
||||
private final long durationUs;
|
||||
private final Function<Long, SeekPoints> seekPointsFunction;
|
||||
|
||||
public FakeSeekMap() {
|
||||
this(C.TIME_UNSET, (timeUs) -> new SeekPoints(SeekPoint.START));
|
||||
}
|
||||
|
||||
public FakeSeekMap(long durationUs, Function<Long, SeekPoints> seekPointsFunction) {
|
||||
this.durationUs = durationUs;
|
||||
this.seekPointsFunction = seekPointsFunction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isSeekable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationUs() {
|
||||
return durationUs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SeekPoints getSeekPoints(long timeUs) {
|
||||
return seekPointsFunction.apply(timeUs);
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeExtractor implements Extractor {
|
||||
|
||||
private final ArrayList<BiFunction<ExtractorInput, PositionHolder, Integer>> readActions;
|
||||
private BiConsumer<Long, Long> seekStrategy;
|
||||
private int nextReadActionIndex;
|
||||
|
||||
public FakeExtractor() {
|
||||
readActions = new ArrayList<>();
|
||||
nextReadActionIndex = 0;
|
||||
seekStrategy = (arg1, arg2) -> {};
|
||||
}
|
||||
|
||||
public void addReadAction(BiFunction<ExtractorInput, PositionHolder, Integer> readAction) {
|
||||
readActions.add(readAction);
|
||||
}
|
||||
|
||||
public void setSeekStrategy(BiConsumer<Long, Long> seekStrategy) {
|
||||
this.seekStrategy = seekStrategy;
|
||||
}
|
||||
|
||||
public void rewindReadActions(long position, long timeUs) {
|
||||
nextReadActionIndex = 0;
|
||||
}
|
||||
|
||||
// Extractor implementation.
|
||||
|
||||
@Override
|
||||
public boolean sniff(ExtractorInput input) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(ExtractorOutput extractorOutput) {
|
||||
MediaExtractorCompatTest.this.extractorOutput = extractorOutput;
|
||||
extractorOutput.seekMap(fakeSeekMap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException {
|
||||
if (nextReadActionIndex >= readActions.size()) {
|
||||
return C.RESULT_END_OF_INPUT;
|
||||
} else {
|
||||
return readActions.get(nextReadActionIndex++).apply(input, seekPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void seek(long position, long timeUs) {
|
||||
seekStrategy.accept(position, timeUs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {}
|
||||
}
|
||||
}
|
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user