Add option to ClippingMediaSource to clip unseekable media

This means we need convert some of the assertions in
ClippingMediaPeriod to contrain the output value to clipped
range instead, because unseekable media will return zero
as a start and seek position in all cases.

PiperOrigin-RevId: 721463824
This commit is contained in:
tonihei 2025-01-30 11:38:15 -08:00 committed by Copybara-Service
parent df575a8d19
commit 39d0881083
5 changed files with 173 additions and 36 deletions

View File

@ -4,6 +4,8 @@
* Common Library:
* ExoPlayer:
* Add option to `ClippingMediaSource` to allow clipping in unseekable
media.
* Transformer:
* Track Selection:
* Extractors:

View File

@ -15,6 +15,9 @@
*/
package androidx.media3.exoplayer.source;
import static java.lang.Math.max;
import static java.lang.Math.min;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
@ -132,18 +135,16 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb
sampleStreams[i] = (ClippingSampleStream) streams[i];
childStreams[i] = sampleStreams[i] != null ? sampleStreams[i].childStream : null;
}
long enablePositionUs =
long realEnablePositionUs =
mediaPeriod.selectTracks(
selections, mayRetainStreamFlags, childStreams, streamResetFlags, positionUs);
long correctedEnablePositionUs =
enforceClippingRange(realEnablePositionUs, /* minPositionUs= */ positionUs, endUs);
pendingInitialDiscontinuityPositionUs =
isPendingInitialDiscontinuity()
&& shouldKeepInitialDiscontinuity(enablePositionUs, selections)
? enablePositionUs
&& shouldKeepInitialDiscontinuity(realEnablePositionUs, positionUs, selections)
? correctedEnablePositionUs
: C.TIME_UNSET;
Assertions.checkState(
enablePositionUs == positionUs
|| (enablePositionUs >= startUs
&& (endUs == C.TIME_END_OF_SOURCE || enablePositionUs <= endUs)));
for (int i = 0; i < streams.length; i++) {
if (childStreams[i] == null) {
sampleStreams[i] = null;
@ -152,7 +153,7 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb
}
streams[i] = sampleStreams[i];
}
return enablePositionUs;
return correctedEnablePositionUs;
}
@Override
@ -178,9 +179,7 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb
if (discontinuityUs == C.TIME_UNSET) {
return C.TIME_UNSET;
}
Assertions.checkState(discontinuityUs >= startUs);
Assertions.checkState(endUs == C.TIME_END_OF_SOURCE || discontinuityUs <= endUs);
return discontinuityUs;
return enforceClippingRange(discontinuityUs, startUs, endUs);
}
@Override
@ -201,11 +200,7 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb
sampleStream.clearSentEos();
}
}
long seekUs = mediaPeriod.seekToUs(positionUs);
Assertions.checkState(
seekUs == positionUs
|| (seekUs >= startUs && (endUs == C.TIME_END_OF_SOURCE || seekUs <= endUs)));
return seekUs;
return enforceClippingRange(mediaPeriod.seekToUs(positionUs), startUs, endUs);
}
@Override
@ -275,7 +270,13 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb
}
private static boolean shouldKeepInitialDiscontinuity(
long startUs, @NullableType ExoTrackSelection[] selections) {
long startUs, long requestedPositionUs, @NullableType ExoTrackSelection[] selections) {
// If the source adjusted the start position to be before the requested position, we need to
// report a discontinuity to ensure renderers decode-only the samples before the requested start
// position.
if (startUs < requestedPositionUs) {
return true;
}
// If the clipping start position is non-zero, the clipping sample streams will adjust
// timestamps on buffers they read from the unclipped sample streams. These adjusted buffer
// timestamps can be negative, because sample streams provide buffers starting at a key-frame,
@ -299,6 +300,15 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb
return false;
}
private static long enforceClippingRange(
long positionUs, long minPositionUs, long maxPositionUs) {
positionUs = max(positionUs, minPositionUs);
if (maxPositionUs != C.TIME_END_OF_SOURCE) {
positionUs = min(positionUs, maxPositionUs);
}
return positionUs;
}
/** Wraps a {@link SampleStream} and clips its samples. */
private final class ClippingSampleStream implements SampleStream {

View File

@ -57,6 +57,7 @@ public final class ClippingMediaSource extends WrappingMediaSource {
private boolean enableInitialDiscontinuity;
private boolean allowDynamicClippingUpdates;
private boolean relativeToDefaultPosition;
private boolean allowUnseekableMedia;
private boolean buildCalled;
/**
@ -195,6 +196,25 @@ public final class ClippingMediaSource extends WrappingMediaSource {
return this;
}
/**
* Sets whether clipping to a non-zero start position in unseekable media is allowed.
*
* <p>Note that this is inefficient because the player needs to read and decode all samples from
* the beginning of the file and it should only be used if the seek start position is small and
* the entire data before the start position fits into memory.
*
* <p>The default value is {@code false}.
*
* @param allowUnseekableMedia Whether a non-zero start position in unseekable media is allowed.
* @return This builder.
*/
@CanIgnoreReturnValue
public Builder setAllowUnseekableMedia(boolean allowUnseekableMedia) {
checkState(!buildCalled);
this.allowUnseekableMedia = allowUnseekableMedia;
return this;
}
/** Builds the {@link ClippingMediaSource}. */
public ClippingMediaSource build() {
buildCalled = true;
@ -259,6 +279,7 @@ public final class ClippingMediaSource extends WrappingMediaSource {
private final boolean enableInitialDiscontinuity;
private final boolean allowDynamicClippingUpdates;
private final boolean relativeToDefaultPosition;
private final boolean allowUnseekableMedia;
private final ArrayList<ClippingMediaPeriod> mediaPeriods;
private final Timeline.Window window;
@ -313,6 +334,7 @@ public final class ClippingMediaSource extends WrappingMediaSource {
this.enableInitialDiscontinuity = builder.enableInitialDiscontinuity;
this.allowDynamicClippingUpdates = builder.allowDynamicClippingUpdates;
this.relativeToDefaultPosition = builder.relativeToDefaultPosition;
this.allowUnseekableMedia = builder.allowUnseekableMedia;
mediaPeriods = new ArrayList<>();
window = new Timeline.Window();
}
@ -398,7 +420,8 @@ public final class ClippingMediaSource extends WrappingMediaSource {
: periodEndUs - windowPositionInPeriodUs;
}
try {
clippingTimeline = new ClippingTimeline(timeline, windowStartUs, windowEndUs);
clippingTimeline =
new ClippingTimeline(timeline, windowStartUs, windowEndUs, allowUnseekableMedia);
} catch (IllegalClippingException e) {
clippingError = e;
// The clipping error won't be propagated while we have existing MediaPeriods. Setting the
@ -426,9 +449,11 @@ public final class ClippingMediaSource extends WrappingMediaSource {
* @param startUs The number of microseconds to clip from the start of {@code timeline}.
* @param endUs The end position in microseconds for the clipped timeline relative to the start
* of {@code timeline}, or {@link C#TIME_END_OF_SOURCE} to clip no samples from the end.
* @param allowUnseekableMedia Whether to allow non-zero start positions in unseekable media.
* @throws IllegalClippingException If the timeline could not be clipped.
*/
public ClippingTimeline(Timeline timeline, long startUs, long endUs)
public ClippingTimeline(
Timeline timeline, long startUs, long endUs, boolean allowUnseekableMedia)
throws IllegalClippingException {
super(timeline);
if (timeline.getPeriodCount() != 1) {
@ -436,7 +461,7 @@ public final class ClippingMediaSource extends WrappingMediaSource {
}
Window window = timeline.getWindow(0, new Window());
startUs = max(0, startUs);
if (!window.isPlaceholder && startUs != 0 && !window.isSeekable) {
if (!allowUnseekableMedia && !window.isPlaceholder && startUs != 0 && !window.isSeekable) {
throw new IllegalClippingException(IllegalClippingException.REASON_NOT_SEEKABLE_TO_START);
}
long resolvedEndUs = endUs == C.TIME_END_OF_SOURCE ? window.durationUs : max(0, endUs);

View File

@ -24,6 +24,7 @@ import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.TrackGroup;
import androidx.media3.common.util.NullableType;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.exoplayer.FormatHolder;
import androidx.media3.exoplayer.LoadingInfo;
@ -173,6 +174,26 @@ public class ClippingMediaPeriodTest {
assertThat(discontinuityPositionUs).isEqualTo(250);
}
@Test
public void
readDiscontinuity_prepareFromNonZeroClipStartPositionWithUnseekableStream_returnsPreparePosition()
throws Exception {
TrackGroupArray trackGroups =
new TrackGroupArray(AUDIO_TRACK_GROUP_ALL_SYNC_SAMPLES, VIDEO_TRACK_GROUP);
ClippingMediaPeriod clippingMediaPeriod =
new ClippingMediaPeriod(
getUnseekableFakeMediaPeriod(trackGroups),
/* enableInitialDiscontinuity= */ true,
/* startUs= */ 250,
/* endUs= */ 500);
prepareMediaPeriodAndSelectTracks(
clippingMediaPeriod, /* preparePositionUs= */ 250, trackGroups);
long discontinuityPositionUs = clippingMediaPeriod.readDiscontinuity();
assertThat(discontinuityPositionUs).isEqualTo(250);
}
@Test
public void readDiscontinuity_prepareFromZero_returnsUnset() throws Exception {
TrackGroupArray trackGroups =
@ -263,6 +284,24 @@ public class ClippingMediaPeriodTest {
assertThat(discontinuityPositionUs).isEqualTo(C.TIME_UNSET);
}
@Test
public void seekTo_withUnseekableMedia_returnsAtLeastStartPositionUs() throws Exception {
TrackGroupArray trackGroups =
new TrackGroupArray(AUDIO_TRACK_GROUP_ALL_SYNC_SAMPLES, VIDEO_TRACK_GROUP);
ClippingMediaPeriod clippingMediaPeriod =
new ClippingMediaPeriod(
getUnseekableFakeMediaPeriod(trackGroups),
/* enableInitialDiscontinuity= */ true,
/* startUs= */ 300,
/* endUs= */ 500);
prepareMediaPeriodAndSelectTracks(
clippingMediaPeriod, /* preparePositionUs= */ 400, trackGroups);
long seekPositionUs = clippingMediaPeriod.seekToUs(350);
assertThat(seekPositionUs).isAtLeast(300);
}
private static SampleStream[] prepareMediaPeriodAndSelectTracks(
MediaPeriod mediaPeriod, long preparePositionUs, TrackGroupArray trackGroups)
throws TimeoutException {
@ -307,4 +346,33 @@ public class ClippingMediaPeriodTest {
new DrmSessionEventListener.EventDispatcher(),
/* deferOnPrepared= */ false);
}
private static FakeMediaPeriod getUnseekableFakeMediaPeriod(TrackGroupArray trackGroups) {
return new FakeMediaPeriod(
trackGroups,
new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024),
/* trackDataFactory= */ (format, mediaPeriodId) -> ImmutableList.of(),
new MediaSourceEventListener.EventDispatcher()
.withParameters(
/* windowIndex= */ 0, new MediaSource.MediaPeriodId(/* periodUid= */ new Object())),
DrmSessionManager.DRM_UNSUPPORTED,
new DrmSessionEventListener.EventDispatcher(),
/* deferOnPrepared= */ false) {
@Override
public long seekToUs(long positionUs) {
return super.seekToUs(/* positionUs= */ 0);
}
@Override
public long selectTracks(
@NullableType ExoTrackSelection[] selections,
boolean[] mayRetainStreamFlags,
@NullableType SampleStream[] streams,
boolean[] streamResetFlags,
long positionUs) {
return super.selectTracks(
selections, mayRetainStreamFlags, streams, streamResetFlags, /* positionUs= */ 0);
}
};
}
}

View File

@ -60,7 +60,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void noClipping() throws IOException {
public void noClipping_returnsExpectedTimeline() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
TEST_PERIOD_DURATION_US,
@ -81,7 +81,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingUnseekableWindowThrows() throws IOException {
public void clipping_withUnseekableWindow_throws() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
TEST_PERIOD_DURATION_US,
@ -103,7 +103,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingUnseekableWindowWithUnknownDurationThrows() throws IOException {
public void clipping_withUnseekableWindowWithUnknownDuration_throws() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
/* durationUs= */ C.TIME_UNSET,
@ -125,7 +125,34 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingStartExceedsEndThrows() throws IOException {
public void clipping_withUnseekableWindowAndAllowedUnseekableMedia_returnsExpectedTimeline()
throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
TEST_PERIOD_DURATION_US,
/* isSeekable= */ false,
/* isDynamic= */ false,
/* useLiveConfiguration= */ false,
/* manifest= */ null,
MediaItem.fromUri(Uri.EMPTY));
FakeMediaSource fakeMediaSource = new FakeMediaSource(timeline);
ClippingMediaSource mediaSource =
new ClippingMediaSource.Builder(fakeMediaSource)
.setStartPositionUs(1)
.setEndPositionUs(TEST_PERIOD_DURATION_US - 1)
.setAllowUnseekableMedia(true)
.build();
Timeline clippedTimeline = getClippedTimelines(fakeMediaSource, mediaSource)[0];
assertThat(clippedTimeline.getWindow(0, window).getDurationUs())
.isEqualTo(TEST_PERIOD_DURATION_US - 2);
assertThat(clippedTimeline.getPeriod(0, period).getDurationUs())
.isEqualTo(TEST_PERIOD_DURATION_US - 1);
}
@Test
public void clipping_startExceedsEnd_throws() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
TEST_PERIOD_DURATION_US,
@ -147,7 +174,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingStart() throws IOException {
public void clipping_startOnly_returnsExpectedTimeline() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
TEST_PERIOD_DURATION_US,
@ -166,7 +193,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingEnd() throws IOException {
public void clipping_endOnly_returnsExpectedTimeline() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
TEST_PERIOD_DURATION_US,
@ -185,7 +212,8 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingStartAndEndInitial() throws IOException {
public void clipping_startAndEndWithInitialPlaceHolderTimeline_returnsExpectedTimeline()
throws IOException {
// Timeline that's dynamic and not seekable. A child source might report such a timeline prior
// to it having loaded sufficient data to establish its duration and seekability. Such timelines
// should not result in clipping failure.
@ -201,7 +229,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingToEndOfSourceWithDurationSetsDuration() throws IOException {
public void clipping_toEndOfSourceWithDuration_setsDuration() throws IOException {
// Create a child timeline that has a known duration.
Timeline timeline =
new SinglePeriodTimeline(
@ -220,7 +248,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingToEndOfSourceWithUnsetDurationDoesNotSetDuration() throws IOException {
public void clipping_toEndOfSourceWithUnsetDuration_doesNotSetDuration() throws IOException {
// Create a child timeline that has an unknown duration.
Timeline timeline =
new SinglePeriodTimeline(
@ -239,7 +267,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingStartAndEnd() throws IOException {
public void clipping_startAndEnd_returnsExpectedTimeline() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
TEST_PERIOD_DURATION_US,
@ -259,7 +287,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void clippingFromDefaultPosition() throws IOException {
public void clipping_fromDefaultPosition_returnsExpectedTimeline() throws IOException {
Timeline timeline =
new SinglePeriodTimeline(
/* periodDurationUs= */ 3 * TEST_PERIOD_DURATION_US,
@ -282,7 +310,8 @@ public final class ClippingMediaSourceTest {
}
@Test
public void allowDynamicUpdatesWithOverlappingLiveWindow() throws IOException {
public void clipping_allowDynamicUpdatesWithOverlappingLiveWindow_returnsExpectedTimelines()
throws IOException {
Timeline timeline1 =
new SinglePeriodTimeline(
/* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US,
@ -333,7 +362,8 @@ public final class ClippingMediaSourceTest {
}
@Test
public void allowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOException {
public void clipping_allowDynamicUpdatesWithNonOverlappingLiveWindow_returnsExpectedTimeline()
throws IOException {
Timeline timeline1 =
new SinglePeriodTimeline(
/* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US,
@ -384,7 +414,8 @@ public final class ClippingMediaSourceTest {
}
@Test
public void disallowDynamicUpdatesWithOverlappingLiveWindow() throws IOException {
public void clipping_disallowDynamicUpdatesWithOverlappingLiveWindow_returnsExpectedTimeline()
throws IOException {
Timeline timeline1 =
new SinglePeriodTimeline(
/* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US,
@ -436,7 +467,8 @@ public final class ClippingMediaSourceTest {
}
@Test
public void disallowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOException {
public void clipping_disallowDynamicUpdatesWithNonOverlappingLiveWindow_returnsExpectedTimeline()
throws IOException {
Timeline timeline1 =
new SinglePeriodTimeline(
/* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US,
@ -486,7 +518,7 @@ public final class ClippingMediaSourceTest {
}
@Test
public void windowAndPeriodIndices() throws IOException {
public void returnsExpectedTimeline_multiWindowAndPeriod_setsCorrectIndices() throws IOException {
Timeline timeline =
new FakeTimeline(
new TimelineWindowDefinition(1, 111, true, false, TEST_PERIOD_DURATION_US));