From 39d0881083180ce8e5e11557bcf70e0f0521b3c4 Mon Sep 17 00:00:00 2001 From: tonihei Date: Thu, 30 Jan 2025 11:38:15 -0800 Subject: [PATCH] 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 --- RELEASENOTES.md | 2 + .../exoplayer/source/ClippingMediaPeriod.java | 44 +++++++----- .../exoplayer/source/ClippingMediaSource.java | 31 ++++++++- .../source/ClippingMediaPeriodTest.java | 68 +++++++++++++++++++ .../source/ClippingMediaSourceTest.java | 64 ++++++++++++----- 5 files changed, 173 insertions(+), 36 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c8dee220aa..f24b17bddf 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,6 +4,8 @@ * Common Library: * ExoPlayer: + * Add option to `ClippingMediaSource` to allow clipping in unseekable + media. * Transformer: * Track Selection: * Extractors: diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java index a887b7d043..912d394024 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java @@ -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 { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java index 3955426006..1fcd3debe3 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java @@ -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. + * + *

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. + * + *

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 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); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java index 1a6ea97992..6ccbdcb252 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java @@ -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); + } + }; + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java index e2f765b6e3..5c985cdc86 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java @@ -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));